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.
13/// Conditionally encrypt an event's content per kind (messages/edits are encrypted at rest). Async,
14/// so callers run it BEFORE opening a sync transaction.
15async fn encrypt_event_content(event: &StoredEvent) -> String {
16    if event.kind == event_kind::CHAT_MESSAGE
17        || event.kind == event_kind::PRIVATE_DIRECT_MESSAGE
18        || event.kind == event_kind::MESSAGE_EDIT
19    {
20        maybe_encrypt(event.content.clone()).await
21    } else {
22        event.content.clone()
23    }
24}
25
26/// Upsert the event row onto the given connection or transaction (so it can commit atomically with
27/// its attachment rows). `content` must already be encrypted (see `encrypt_event_content`).
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.
32fn insert_event_row(conn: &rusqlite::Connection, event: &StoredEvent, content: &str, tags_json: &str) -> Result<(), String> {
33    // prepare_cached: this is the hottest write statement in the app — the cache lives on the
34    // connection, so bulk-sync batches and every realtime save skip the SQL re-parse.
35    let mut stmt = conn.prepare_cached(
36        r#"
37        INSERT INTO events (
38            id, kind, chat_id, user_id, content, tags, reference_id,
39            created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata
40        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
41        ON CONFLICT(id) DO UPDATE SET
42            kind = excluded.kind, chat_id = excluded.chat_id, user_id = excluded.user_id,
43            content = excluded.content, tags = excluded.tags, reference_id = excluded.reference_id,
44            created_at = excluded.created_at, received_at = excluded.received_at,
45            mine = excluded.mine, pending = excluded.pending, failed = excluded.failed,
46            wrapper_event_id = COALESCE(excluded.wrapper_event_id, events.wrapper_event_id),
47            npub = excluded.npub, preview_metadata = excluded.preview_metadata
48        "#,
49    ).map_err(|e| format!("prepare save event: {}", e))?;
50    stmt.execute(
51        rusqlite::params![
52            event.id, event.kind as i32, event.chat_id, event.user_id, content, tags_json,
53            event.reference_id, event.created_at as i64, event.received_at as i64,
54            event.mine as i32, event.pending as i32, event.failed as i32,
55            event.wrapper_event_id, event.npub, event.preview_metadata,
56        ],
57    ).map_err(|e| format!("Failed to save event: {}", e))?;
58    Ok(())
59}
60
61pub async fn save_event(event: &StoredEvent) -> Result<(), String> {
62    let tags_json = serde_json::to_string(&event.tags).unwrap_or_else(|_| "[]".to_string());
63    let content = encrypt_event_content(event).await;
64    let conn = super::get_write_connection_guard_static()?;
65    insert_event_row(&conn, event, &content, &tags_json)
66}
67
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    event_exists_on(&conn, event_id)
89}
90
91/// `event_exists` against a caller-held connection or transaction — an in-transaction check
92/// sees the batch's own uncommitted rows, which the pooled read connection cannot.
93fn event_exists_on(conn: &rusqlite::Connection, event_id: &str) -> Result<bool, String> {
94    let mut stmt = conn.prepare_cached("SELECT EXISTS(SELECT 1 FROM events WHERE id = ?1)")
95        .map_err(|e| format!("prepare event existence: {}", e))?;
96    stmt.query_row(rusqlite::params![event_id], |row| row.get(0))
97        .map_err(|e| format!("Failed to check event existence: {}", e))
98}
99
100/// Build the kind=7 StoredEvent for a reaction (shared by the single-save and batch paths).
101fn reaction_to_stored_event(
102    reaction: &Reaction,
103    chat_id: i64,
104    user_id: Option<i64>,
105    mine: bool,
106    wrapper_event_id: Option<String>,
107) -> StoredEvent {
108    // Persist the NIP-30 emoji tag alongside the `e` reference so the
109    // image URL survives reload — pure `arrEmojiPacks` lookup would
110    // fail when the user hasn't yet opened the picker or unsubscribed.
111    let mut tags: Vec<Vec<String>> = vec![
112        vec!["e".to_string(), reaction.reference_id.clone()],
113    ];
114    if let Some(url) = &reaction.emoji_url {
115        if reaction.emoji.starts_with(':') && reaction.emoji.ends_with(':') && reaction.emoji.len() >= 3 {
116            let shortcode = &reaction.emoji[1..reaction.emoji.len() - 1];
117            if !shortcode.is_empty() && !url.is_empty() {
118                tags.push(vec!["emoji".to_string(), shortcode.to_string(), url.clone()]);
119            }
120        }
121    }
122    StoredEvent {
123        id: reaction.id.clone(),
124        kind: event_kind::REACTION,
125        chat_id,
126        user_id,
127        content: reaction.emoji.clone(),
128        tags,
129        reference_id: Some(reaction.reference_id.clone()),
130        created_at: std::time::SystemTime::now()
131            .duration_since(std::time::UNIX_EPOCH)
132            .map(|d| d.as_secs()).unwrap_or(0),
133        received_at: std::time::SystemTime::now()
134            .duration_since(std::time::UNIX_EPOCH)
135            .map(|d| d.as_millis() as u64).unwrap_or(0),
136        mine,
137        pending: false,
138        failed: false,
139        wrapper_event_id,
140        npub: Some(reaction.author_id.clone()),
141        preview_metadata: None,
142    }
143}
144
145/// Save a reaction as a kind=7 event referencing the message.
146pub async fn save_reaction_event(
147    reaction: &Reaction,
148    chat_id: i64,
149    user_id: Option<i64>,
150    mine: bool,
151    wrapper_event_id: Option<String>,
152) -> Result<(), String> {
153    let event = reaction_to_stored_event(reaction, chat_id, user_id, mine, wrapper_event_id);
154    save_event(&event).await
155}
156
157// ============================================================================
158// save_message — Message → StoredEvent → DB
159// ============================================================================
160
161/// Save a single message to the database.
162///
163/// Converts Message to StoredEvent and saves via the flat event architecture.
164/// Also saves reactions as separate kind=7 events.
165pub async fn save_message(chat_id: &str, message: &Message) -> Result<(), String> {
166    let chat_int_id = super::id_cache::get_or_create_chat_id(chat_id)?;
167
168    let user_int_id = if let Some(ref npub_str) = message.npub {
169        super::id_cache::get_or_create_user_id(npub_str)?
170    } else {
171        None
172    };
173
174    let event = message_to_stored_event(message, chat_int_id, user_int_id);
175
176    // Commit the event row and its attachment rows (the dedicated table is the sole source of truth;
177    // pre-migration events keep their legacy tag as an un-read fallback) in ONE transaction, so a
178    // file message can never persist without its attachments — new events have no tag to fall back
179    // on. Encrypt first: encryption is async and can't run inside the sync transaction.
180    let tags_json = serde_json::to_string(&event.tags).unwrap_or_else(|_| "[]".to_string());
181    let content = encrypt_event_content(&event).await;
182    {
183        let conn = super::get_write_connection_guard_static()?;
184        let tx = conn.unchecked_transaction().map_err(|e| format!("save_message tx: {e}"))?;
185        insert_event_row(&tx, &event, &content, &tags_json)?;
186        super::attachments::insert_attachment_rows(&tx, &message.id, &message.attachments)?;
187        tx.commit().map_err(|e| format!("save_message commit: {e}"))?;
188    }
189
190    // Save reactions as separate kind=7 events
191    for reaction in &message.reactions {
192        if !event_exists(&reaction.id)? {
193            let user_id = super::id_cache::get_or_create_user_id(&reaction.author_id)?;
194            let is_mine = super::get_current_account()
195                .map(|npub| reaction.author_id == npub)
196                .unwrap_or(false);
197            save_reaction_event(reaction, chat_int_id, user_id, is_mine, None).await?;
198        }
199    }
200
201    Ok(())
202}
203
204/// One fully-prepared batch row: the message, its encrypted event row, its prepared
205/// reaction rows, and (DM stream only) the gift-wrap ledger entry that must commit in the
206/// SAME transaction — everything phase 2 needs with zero async work and zero id_cache calls.
207struct BatchRow<'a> {
208    message: &'a Message,
209    event: StoredEvent,
210    content: String,
211    tags_json: String,
212    reactions: Vec<(StoredEvent, String)>,
213    /// `(wrapper_id_bytes, wrapper_created_at)` — written to `processed_wrappers` only
214    /// AFTER this row lands. The ledger is the negentropy fingerprint set: a wrapper
215    /// ledgered before its row commits marks the message "have" forever if the row is lost.
216    wrapper: Option<([u8; 32], u64)>,
217}
218
219/// Phase 1 of a batched save (async): resolve ids, build StoredEvents, encrypt contents.
220/// ALL id_cache lookups happen here — get_or_create can write a fresh chat/user row, so it
221/// must never run while phase 2 holds the write-connection guard.
222async fn prepare_batch_rows<'a>(
223    chat_id: &str,
224    messages: &[(&'a Message, Option<([u8; 32], u64)>)],
225    rows: &mut Vec<BatchRow<'a>>,
226) -> Result<(), String> {
227    let chat_int_id = super::id_cache::get_or_create_chat_id(chat_id)?;
228    let my_npub = super::get_current_account();
229    for (message, wrapper) in messages {
230        let user_int_id = match &message.npub {
231            Some(npub_str) => super::id_cache::get_or_create_user_id(npub_str)?,
232            None => None,
233        };
234        let event = message_to_stored_event(message, chat_int_id, user_int_id);
235        let tags_json = serde_json::to_string(&event.tags).unwrap_or_else(|_| "[]".to_string());
236        let content = encrypt_event_content(&event).await;
237        let mut reactions: Vec<(StoredEvent, String)> = Vec::with_capacity(message.reactions.len());
238        for reaction in &message.reactions {
239            let user_id = super::id_cache::get_or_create_user_id(&reaction.author_id)?;
240            let is_mine = my_npub.as_deref().map(|n| reaction.author_id == n).unwrap_or(false);
241            let rev = reaction_to_stored_event(reaction, chat_int_id, user_id, is_mine, None);
242            let rtags = serde_json::to_string(&rev.tags).unwrap_or_else(|_| "[]".to_string());
243            reactions.push((rev, rtags));
244        }
245        rows.push(BatchRow { message, event, content, tags_json, reactions, wrapper: *wrapper });
246    }
247    Ok(())
248}
249
250/// Phase 2 of a batched save (sync): ONE transaction for every event + attachment + reaction
251/// + wrapper-ledger row. Insert order follows slice order, preserving the rowid tiebreak
252/// that same-timestamp pagination depends on. A poison message SKIPS (logged) rather than
253/// aborting the batch — one bad row must not lose the other 49. Returns how many messages
254/// were written.
255fn write_batch_rows(rows: &[BatchRow<'_>]) -> Result<usize, String> {
256    let conn = super::get_write_connection_guard_static()?;
257    let tx = conn.unchecked_transaction().map_err(|e| format!("batch tx: {e}"))?;
258    let mut saved = 0usize;
259    for row in rows {
260        // Per-row savepoint = save_message's per-message atomicity inside the batch: a
261        // failed event/attachment write unwinds THIS row completely — including partial
262        // attachment upserts onto a pre-existing row, which a bare DELETE would destroy
263        // (a re-saved old file message must keep its download record on a transient error).
264        tx.execute_batch("SAVEPOINT batch_row").map_err(|e| format!("batch savepoint: {e}"))?;
265        let row_written = insert_event_row(&tx, &row.event, &row.content, &row.tags_json)
266            .and_then(|_| super::attachments::insert_attachment_rows(&tx, &row.message.id, &row.message.attachments));
267        if let Err(e) = row_written {
268            crate::log_warn!("[DB] batch skip {}: {}", &row.message.id[..8.min(row.message.id.len())], e);
269            let _ = tx.execute_batch("ROLLBACK TO batch_row; RELEASE batch_row");
270            continue;
271        }
272        saved += 1;
273        for (rev, rtags) in &row.reactions {
274            // Exists-check ON the tx so a reaction already inserted earlier in this batch dedups
275            // (a fresh reaction row must not clobber one that arrived with a wrapper id).
276            if event_exists_on(&tx, &rev.id).unwrap_or(true) {
277                continue;
278            }
279            if let Err(e) = insert_event_row(&tx, rev, &rev.content, rtags) {
280                crate::log_warn!("[DB] batch reaction {}: {}", &rev.id[..8.min(rev.id.len())], e);
281            }
282        }
283        // Ledger the gift-wrap only now that its row is in the tx — commit lands both or neither.
284        if let Some((wrapper_id, wrapper_created_at)) = &row.wrapper {
285            let mut stmt = tx.prepare_cached(
286                "INSERT OR IGNORE INTO processed_wrappers (wrapper_id, wrapper_created_at, transport) VALUES (?1, ?2, ?3)",
287            ).map_err(|e| format!("prepare wrapper ledger: {e}"))?;
288            if let Err(e) = stmt.execute(rusqlite::params![
289                &wrapper_id[..], *wrapper_created_at as i64, super::wrappers::TRANSPORT_NIP17,
290            ]) {
291                crate::log_warn!("[DB] batch wrapper ledger {}: {}", &row.message.id[..8.min(row.message.id.len())], e);
292            }
293        }
294        tx.execute_batch("RELEASE batch_row").map_err(|e| format!("batch release: {e}"))?;
295    }
296    tx.commit().map_err(|e| format!("batch commit: {e}"))?;
297    Ok(saved)
298}
299
300/// Save many messages for one chat in a SINGLE transaction — the bulk-sync persist path
301/// (community backfill pages, negentropy catch-up). One commit amortizes the per-transaction
302/// WAL overhead across the whole page instead of paying it per message.
303///
304/// Structure mirrors `save_message` exactly: contents are encrypted FIRST (encryption is
305/// async and can't run inside the sync transaction), then one transaction writes every event
306/// row + its attachment rows + its reaction rows (kind-7 content is never encrypted at rest,
307/// so reactions are tx-safe).
308///
309/// `session`: phase 1 awaits through encryption + id resolution, so a swap can land inside
310/// it — when provided, the guard is re-checked between the phases and a stale batch is
311/// dropped before it can write into the next account's DB.
312pub async fn save_messages_batch(
313    chat_id: &str,
314    messages: &[&Message],
315) -> Result<usize, String> {
316    crate::db::scoped(async move {
317        if messages.is_empty() {
318            return Ok(0);
319        }
320        let with_wrappers: Vec<(&Message, Option<([u8; 32], u64)>)> =
321            messages.iter().map(|m| (*m, None)).collect();
322        let mut rows = Vec::with_capacity(messages.len());
323        prepare_batch_rows(chat_id, &with_wrappers, &mut rows).await?;
324        write_batch_rows(&rows)
325    })
326    .await
327}
328
329/// Multi-chat variant for the DM sync stream: gift-wrapped messages span many contacts, and
330/// splitting per chat would give back most of the batching win. Each message may carry its
331/// gift-wrap ledger entry, committed in the SAME transaction right after its row (see
332/// `BatchRow::wrapper`). Groups keep their slice order; everything lands in ONE transaction.
333pub async fn save_messages_batch_multi(
334    groups: &[(String, Vec<(&Message, Option<([u8; 32], u64)>)>)],
335) -> Result<usize, String> {
336    crate::db::scoped(async move {
337        let total: usize = groups.iter().map(|(_, m)| m.len()).sum();
338        if total == 0 {
339            return Ok(0);
340        }
341        let mut rows = Vec::with_capacity(total);
342        for (chat_id, messages) in groups {
343            prepare_batch_rows(chat_id, messages, &mut rows).await?;
344        }
345        write_batch_rows(&rows)
346    })
347    .await
348}
349
350/// Convert a Message to a StoredEvent.
351fn message_to_stored_event(message: &Message, chat_id: i64, user_id: Option<i64>) -> StoredEvent {
352    let kind = if !message.attachments.is_empty() {
353        event_kind::FILE_ATTACHMENT
354    } else {
355        event_kind::PRIVATE_DIRECT_MESSAGE
356    };
357
358    let mut tags: Vec<Vec<String>> = Vec::new();
359
360    // Millisecond precision tag
361    let ms = message.at % 1000;
362    if ms > 0 {
363        tags.push(vec!["ms".to_string(), ms.to_string()]);
364    }
365
366    // Reply reference
367    if !message.replied_to.is_empty() {
368        tags.push(vec![
369            "e".to_string(),
370            message.replied_to.clone(),
371            "".to_string(),
372            "reply".to_string(),
373        ]);
374    }
375
376    // Attachments are stored in the dedicated `attachments` table (see save_message), not a tag.
377
378    // NIP-30 emoji tags — persist so reload from DB still renders the
379    // custom emoji image instead of the literal `:shortcode:`.
380    for et in &message.emoji_tags {
381        tags.push(vec!["emoji".to_string(), et.shortcode.clone(), et.url.clone()]);
382    }
383
384    // Bot routing targets (npubs) — persist so the passive "ran /cmd with
385    // Bot" render survives a reload.
386    for npub in &message.addressed_bots {
387        tags.push(vec!["bot".to_string(), npub.clone()]);
388    }
389
390    // NIP-40 self-destruct expiry — persist so the countdown + purge survive a
391    // reload. Rides the same tags column as every other message-shaped tag.
392    if let Some(exp) = message.expiration {
393        tags.push(vec!["expiration".to_string(), exp.to_string()]);
394    }
395
396    let preview_metadata = message.preview_metadata.as_ref()
397        .and_then(|m| serde_json::to_string(m).ok());
398
399    StoredEvent {
400        id: message.id.clone(),
401        kind,
402        chat_id,
403        user_id,
404        content: message.content.clone(),
405        tags,
406        reference_id: None,
407        created_at: message.at / 1000,
408        received_at: std::time::SystemTime::now()
409            .duration_since(std::time::UNIX_EPOCH)
410            .map(|d| d.as_millis() as u64)
411            .unwrap_or(0),
412        mine: message.mine,
413        pending: message.pending,
414        failed: message.failed,
415        wrapper_event_id: message.wrapper_event_id.clone(),
416        npub: message.npub.clone(),
417        preview_metadata,
418    }
419}
420
421/// Save a PIVX payment event, resolving chat_id from conversation identifier.
422pub async fn save_pivx_payment_event(
423    conversation_id: &str,
424    mut event: StoredEvent,
425) -> Result<(), String> {
426    event.chat_id = super::id_cache::get_or_create_chat_id(conversation_id)?;
427    save_event(&event).await
428}
429
430/// Save a system event (member joined/left/removed) with dedup.
431/// Returns true if inserted, false if duplicate.
432pub async fn save_system_event_by_id(
433    event_id: &str,
434    conversation_id: &str,
435    event_type: crate::stored_event::SystemEventType,
436    member_npub: &str,
437    member_name: Option<&str>,
438) -> Result<bool, String> {
439    let now_secs = std::time::SystemTime::now()
440        .duration_since(std::time::UNIX_EPOCH)
441        .map(|d| d.as_secs()).unwrap_or(0);
442    save_system_event_at(event_id, conversation_id, event_type, member_npub, member_name, now_secs, None, None).await
443}
444
445/// Like [`save_system_event_by_id`] but stamps `created_at` from the event's own authenticated timestamp
446/// (clamped to not exceed local now, since the inner author sets it) so a HISTORICALLY-synced presence
447/// (join/leave) sorts at the time it happened, not at ingest-time now. `received_at` stays local now.
448pub async fn save_system_event_at(
449    event_id: &str,
450    conversation_id: &str,
451    event_type: crate::stored_event::SystemEventType,
452    member_npub: &str,
453    member_name: Option<&str>,
454    created_at_secs: u64,
455    // Join attribution (public invites): who minted the link the member joined via, and its label.
456    // Stored as queryable tags so per-link join counts fall out of a tag scan.
457    invited_by: Option<&str>,
458    invited_label: Option<&str>,
459) -> Result<bool, String> {
460    // A blank conversation id is a caller bug, never a conversation. Left to
461    // `get_or_create_chat_id` it MINTS a chat row keyed by "" — and since the
462    // identifier is UNIQUE, every such write from every community collapses into
463    // one phantom row that nothing can open and the boot sweep can only report.
464    // A blank conversation id is a caller bug, never a conversation. Left to
465    // `get_or_create_chat_id` it MINTS a chat row keyed by "" — and since the
466    // identifier is UNIQUE, every such write from every community collapses into
467    // one phantom row that nothing can open and the boot sweep can only report.
468    if conversation_id.trim().is_empty() {
469        return Err("system event has no conversation id".to_string());
470    }
471    let chat_id = super::id_cache::get_or_create_chat_id(conversation_id)?;
472
473    let now_secs = std::time::SystemTime::now()
474        .duration_since(std::time::UNIX_EPOCH)
475        .map(|d| d.as_secs()).unwrap_or(0);
476    // Author-set timestamp: clamp forward so a future-dated event can't jump ahead of real activity.
477    let created_at = created_at_secs.min(now_secs);
478
479    let display_name = member_name.unwrap_or(member_npub);
480    let content = event_type.display_message(display_name);
481
482    let mut tags: Vec<Vec<String>> = vec![
483        vec!["d".to_string(), "system-event".to_string()],
484        vec!["event-type".to_string(), event_type.as_u8().to_string()],
485        vec!["member".to_string(), member_npub.to_string()],
486    ];
487    if let Some(by) = invited_by {
488        tags.push(vec!["invited-by".to_string(), by.to_string()]);
489        if let Some(l) = invited_label.filter(|l| !l.is_empty()) {
490            tags.push(vec!["invited-label".to_string(), l.to_string()]);
491        }
492    }
493    let tags_json = serde_json::to_string(&tags)
494        .map_err(|e| format!("Failed to serialize tags: {}", e))?;
495
496    let conn = super::get_write_connection_guard_static()?;
497    let rows = conn.execute(
498        r#"INSERT OR IGNORE INTO events (
499            id, kind, chat_id, user_id, content, tags, reference_id,
500            created_at, received_at, mine, pending, failed, wrapper_event_id, npub
501        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)"#,
502        rusqlite::params![
503            event_id,
504            event_kind::APPLICATION_SPECIFIC as i32,
505            chat_id, None::<i64>, content, tags_json, None::<String>,
506            created_at as i64, now_secs as i64,
507            0, 0, 0, None::<String>, member_npub,
508        ],
509    ).map_err(|e| format!("Failed to save system event: {}", e))?;
510
511    Ok(rows > 0)
512}
513
514/// Save a message edit as a kind=16 event referencing the original message.
515pub async fn save_edit_event(
516    edit_id: &str,
517    message_id: &str,
518    new_content: &str,
519    emoji_tags: &[crate::types::EmojiTag],
520    chat_id: i64,
521    user_id: Option<i64>,
522    npub: &str,
523) -> Result<(), String> {
524    let now = std::time::SystemTime::now()
525        .duration_since(std::time::UNIX_EPOCH).unwrap();
526
527    // Carry NIP-30 emoji tags so a reload renders the edit's custom emoji image
528    // (the reload fold reads the latest edit's tags, not the original message's).
529    let mut tags = vec![
530        vec!["e".to_string(), message_id.to_string(), "".to_string(), "edit".to_string()],
531    ];
532    for et in emoji_tags {
533        tags.push(vec!["emoji".to_string(), et.shortcode.clone(), et.url.clone()]);
534    }
535
536    let event = StoredEvent {
537        id: edit_id.to_string(),
538        kind: event_kind::MESSAGE_EDIT,
539        chat_id,
540        user_id,
541        content: new_content.to_string(),
542        tags,
543        reference_id: Some(message_id.to_string()),
544        created_at: now.as_secs(),
545        received_at: now.as_millis() as u64,
546        mine: true,
547        pending: false,
548        failed: false,
549        wrapper_event_id: None,
550        npub: Some(npub.to_string()),
551        preview_metadata: None,
552    };
553
554    save_event(&event).await
555}
556
557/// Delete an event from the events table by ID.
558pub async fn delete_event(event_id: &str) -> Result<(), String> {
559    let conn = super::get_write_connection_guard_static()?;
560    // If this row is a chat's read marker, retreat it to the newest surviving event before it FIRST.
561    // A deleted marker would leave `last_read` dangling and collapse the unread anchor (badge stuck
562    // at 99+). The UPDATE fires only when this chat's marker is exactly the row being deleted.
563    if let Ok(Some((chat_row, at))) = conn.query_row(
564        "SELECT chat_id, created_at FROM events WHERE id = ?1",
565        rusqlite::params![event_id],
566        |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)),
567    ).optional() {
568        conn.execute(
569            "UPDATE chats SET last_read = COALESCE(( \
570                 SELECT id FROM events WHERE chat_id = ?1 AND id != ?2 AND created_at <= ?3 \
571                 ORDER BY created_at DESC, id DESC LIMIT 1), '') \
572             WHERE id = ?1 AND last_read = ?2",
573            rusqlite::params![chat_row, event_id, at],
574        ).map_err(|e| format!("read-marker retreat: {e}"))?;
575    }
576    conn.execute(
577        "DELETE FROM events WHERE id = ?1",
578        rusqlite::params![event_id],
579    ).map_err(|e| format!("Failed to delete event: {}", e))?;
580    Ok(())
581}
582
583/// Record a durable delete tombstone for `event_id`. The events row is gone after a
584/// delete, so this is the only thing that lets ingest refuse a wrap a relay
585/// re-serves across restarts (NIP-09 is best-effort — relays ignore it freely).
586pub fn add_message_tombstone(event_id: &str) -> Result<(), String> {
587    let conn = super::get_write_connection_guard_static()?;
588    let now = std::time::SystemTime::now()
589        .duration_since(std::time::UNIX_EPOCH)
590        .map(|d| d.as_secs() as i64)
591        .unwrap_or(0);
592    conn.execute(
593        "INSERT OR IGNORE INTO deleted_messages (event_id, deleted_at) VALUES (?1, ?2)",
594        rusqlite::params![event_id, now],
595    ).map_err(|e| format!("Failed to save delete tombstone: {}", e))?;
596    Ok(())
597}
598
599/// Every recorded delete tombstone, for seeding the in-session set at account init.
600pub fn load_message_tombstones() -> Result<Vec<String>, String> {
601    let conn = super::get_db_connection_guard_static()?;
602    let mut stmt = conn.prepare("SELECT event_id FROM deleted_messages")
603        .map_err(|e| format!("Failed to prepare tombstone query: {}", e))?;
604    let ids = stmt.query_map([], |row| row.get::<_, String>(0))
605        .map_err(|e| format!("Failed to query tombstones: {}", e))?
606        .filter_map(|r| r.ok())
607        .collect();
608    Ok(ids)
609}
610
611/// The stored author (npub) of an event, or `None` if the row (or DB) is absent. Lets the
612/// out-of-window moderation-hide path authorize against a paged-out message's real author.
613pub fn event_author(event_id: &str) -> Result<Option<String>, String> {
614    let conn = match super::get_db_connection_guard_static() {
615        Ok(c) => c,
616        Err(_) => return Ok(None),
617    };
618    conn.query_row(
619        "SELECT npub FROM events WHERE id = ?1",
620        rusqlite::params![event_id],
621        |row| row.get::<_, Option<String>>(0),
622    )
623    .optional()
624    .map(|o| o.flatten())
625    .map_err(|e| format!("Failed to read event author: {}", e))
626}
627
628/// The owning chat identifier, `mine` flag, and stored author (npub) of an event, or
629/// `None` if the row (or DB) is absent. Lets delete-affordance resolution give paged-out
630/// rows the same verdict as resident ones — residency is a cache detail, not a verdict.
631pub fn event_delete_context(event_id: &str) -> Result<Option<(String, bool, Option<String>)>, String> {
632    let conn = match super::get_db_connection_guard_static() {
633        Ok(c) => c,
634        Err(_) => return Ok(None),
635    };
636    conn.query_row(
637        "SELECT c.chat_identifier, e.mine, e.npub \
638         FROM events e JOIN chats c ON c.id = e.chat_id \
639         WHERE e.id = ?1",
640        rusqlite::params![event_id],
641        |row| {
642            Ok((
643                row.get::<_, String>(0)?,
644                row.get::<_, i32>(1)? != 0,
645                row.get::<_, Option<String>>(2)?,
646            ))
647        },
648    )
649    .optional()
650    .map_err(|e| format!("Failed to read event delete context: {}", e))
651}
652
653/// Check if a message/event exists in the database. Returns false if DB unavailable.
654pub fn message_exists_in_db(message_id: &str) -> Result<bool, String> {
655    let conn = match super::get_db_connection_guard_static() {
656        Ok(c) => c,
657        Err(_) => return Ok(false),
658    };
659    conn.query_row(
660        "SELECT EXISTS(SELECT 1 FROM events WHERE id = ?1)",
661        rusqlite::params![message_id],
662        |row| row.get(0),
663    ).map_err(|e| format!("Failed to check event existence: {}", e))
664}
665
666/// Check if a wrapper (giftwrap) event ID exists. Returns false if DB unavailable.
667pub fn wrapper_event_exists(wrapper_event_id: &str) -> Result<bool, String> {
668    let conn = match super::get_db_connection_guard_static() {
669        Ok(c) => c,
670        Err(_) => return Ok(false),
671    };
672    conn.query_row(
673        "SELECT EXISTS(SELECT 1 FROM events WHERE wrapper_event_id = ?1)",
674        rusqlite::params![wrapper_event_id],
675        |row| row.get(0),
676    ).map_err(|e| format!("Failed to check wrapper event existence: {}", e))
677}
678
679/// Update the wrapper event ID for an existing event.
680/// Returns true if updated, false if event already had a wrapper_id.
681pub fn update_wrapper_event_id(event_id: &str, wrapper_event_id: &str) -> Result<bool, String> {
682    let conn = match super::get_write_connection_guard_static() {
683        Ok(c) => c,
684        Err(_) => return Ok(false),
685    };
686    let rows = conn.execute(
687        "UPDATE events SET wrapper_event_id = ?1 WHERE id = ?2 AND (wrapper_event_id IS NULL OR wrapper_event_id = '')",
688        rusqlite::params![wrapper_event_id, event_id],
689    ).map_err(|e| format!("Failed to update wrapper event ID: {}", e))?;
690    Ok(rows > 0)
691}
692
693/// Get message count for a chat.
694pub fn get_chat_message_count(chat_id: i64) -> Result<usize, String> {
695    let conn = super::get_db_connection_guard_static()?;
696    // Must count the SAME kinds get_message_views returns (community chat 9, DM 14, file 15). A
697    // narrower set under-counts vs. the rows actually loaded, which latches the frontend cache's
698    // `isFullyLoaded` flag true and wedges the local back-pager — community channels then never
699    // page DB history past the first screen.
700    let count: i64 = conn.query_row(
701        &format!(
702            "SELECT COUNT(*) FROM events WHERE chat_id = ?1 AND kind IN ({}, {}, {})",
703            event_kind::CHAT_MESSAGE, event_kind::PRIVATE_DIRECT_MESSAGE, event_kind::FILE_ATTACHMENT
704        ),
705        rusqlite::params![chat_id],
706        |row| row.get(0),
707    ).map_err(|e| format!("Failed to count messages: {}", e))?;
708    Ok(count as usize)
709}
710
711/// Get PIVX payment events for a chat.
712pub fn get_pivx_payments_for_chat(conversation_id: &str) -> Result<Vec<StoredEvent>, String> {
713    let conn = super::get_db_connection_guard_static()?;
714    let chat_id: i64 = conn.query_row(
715        "SELECT id FROM chats WHERE chat_identifier = ?1",
716        rusqlite::params![conversation_id], |row| row.get(0)
717    ).map_err(|_| "Chat not found")?;
718
719    let mut stmt = conn.prepare(
720        "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
721         created_at, received_at, mine, pending, failed, wrapper_event_id, npub \
722         FROM events WHERE chat_id = ?1 AND kind = ?2 ORDER BY created_at ASC, received_at ASC"
723    ).map_err(|e| format!("Failed to prepare: {}", e))?;
724
725    let rows = stmt.query_map(
726        rusqlite::params![chat_id, event_kind::APPLICATION_SPECIFIC as i32],
727        |row| {
728            let tags_json: String = row.get(5)?;
729            let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
730            Ok(StoredEvent {
731                id: row.get(0)?, kind: row.get::<_, i32>(1)? as u16,
732                chat_id: row.get(2)?, user_id: row.get(3)?, content: row.get(4)?,
733                tags, reference_id: row.get(6)?,
734                created_at: row.get::<_, i64>(7)? as u64, received_at: row.get::<_, i64>(8)? as u64,
735                mine: row.get::<_, i32>(9)? != 0, pending: row.get::<_, i32>(10)? != 0,
736                failed: row.get::<_, i32>(11)? != 0, wrapper_event_id: row.get(12)?,
737                npub: row.get(13)?, preview_metadata: None,
738            })
739        }
740    ).map_err(|e| format!("Failed to query: {}", e))?;
741
742    let mut payments = Vec::new();
743    for row in rows {
744        let event = row.map_err(|e| format!("Failed to read event: {}", e))?;
745        if event.tags.iter().any(|t| t.len() >= 2 && t[0] == "d" && t[1] == "pivx-payment") {
746            payments.push(event);
747        }
748    }
749    Ok(payments)
750}
751
752/// Get system events (member joined/left) for a chat.
753pub fn get_system_events_for_chat(conversation_id: &str) -> Result<Vec<StoredEvent>, String> {
754    let conn = super::get_db_connection_guard_static()?;
755    let chat_id: i64 = conn.query_row(
756        "SELECT id FROM chats WHERE chat_identifier = ?1",
757        rusqlite::params![conversation_id], |row| row.get(0)
758    ).map_err(|_| "Chat not found")?;
759
760    let mut stmt = conn.prepare(
761        "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
762         created_at, received_at, mine, pending, failed, wrapper_event_id, npub \
763         FROM events WHERE chat_id = ?1 AND kind = ?2 ORDER BY created_at ASC, received_at ASC"
764    ).map_err(|e| format!("Failed to prepare: {}", e))?;
765
766    let rows = stmt.query_map(
767        rusqlite::params![chat_id, event_kind::APPLICATION_SPECIFIC as i32],
768        |row| {
769            let tags_json: String = row.get(5)?;
770            let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
771            Ok(StoredEvent {
772                id: row.get(0)?, kind: row.get::<_, i32>(1)? as u16,
773                chat_id: row.get(2)?, user_id: row.get(3)?, content: row.get(4)?,
774                tags, reference_id: row.get(6)?,
775                created_at: row.get::<_, i64>(7)? as u64, received_at: row.get::<_, i64>(8)? as u64,
776                mine: row.get::<_, i32>(9)? != 0, pending: row.get::<_, i32>(10)? != 0,
777                failed: row.get::<_, i32>(11)? != 0, wrapper_event_id: row.get(12)?,
778                npub: row.get(13)?, preview_metadata: None,
779            })
780        }
781    ).map_err(|e| format!("Failed to query: {}", e))?;
782
783    let mut events = Vec::new();
784    for row in rows {
785        let event = row.map_err(|e| format!("Failed to read event: {}", e))?;
786        if event.tags.iter().any(|t| t.len() >= 2 && t[0] == "d" && t[1] == "system-event") {
787            events.push(event);
788        }
789    }
790    Ok(events)
791}
792
793// ============================================================================
794// Event Read Operations
795// ============================================================================
796
797/// Helper to parse a SQLite row into a StoredEvent.
798fn parse_event_row(row: &rusqlite::Row) -> rusqlite::Result<StoredEvent> {
799    let tags_json: String = row.get(5)?;
800    let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
801
802    Ok(StoredEvent {
803        id: row.get(0)?,
804        kind: row.get::<_, i32>(1)? as u16,
805        chat_id: row.get(2)?,
806        user_id: row.get(3)?,
807        content: row.get(4)?,
808        tags,
809        reference_id: row.get(6)?,
810        created_at: row.get::<_, i64>(7)? as u64,
811        received_at: row.get::<_, i64>(8)? as u64,
812        mine: row.get::<_, i32>(9)? != 0,
813        pending: row.get::<_, i32>(10)? != 0,
814        failed: row.get::<_, i32>(11)? != 0,
815        wrapper_event_id: row.get(12)?,
816        npub: row.get(13)?,
817        preview_metadata: row.get(14)?,
818    })
819}
820
821/// Get events for a chat with pagination, optionally filtered by kind.
822/// Message/edit content is decrypted via maybe_decrypt.
823pub async fn get_events(
824    chat_id: i64,
825    kinds: Option<&[u16]>,
826    limit: usize,
827    offset: usize,
828) -> Result<Vec<StoredEvent>, String> {
829    let events: Vec<StoredEvent> = {
830        let conn = super::get_db_connection_guard_static()?;
831
832        if let Some(k) = kinds {
833            let kind_placeholders: String = (0..k.len())
834                .map(|i| format!("?{}", i + 2))
835                .collect::<Vec<_>>()
836                .join(",");
837            let limit_param = k.len() + 2;
838            let offset_param = k.len() + 3;
839
840            let sql = format!(
841                "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
842                 created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata \
843                 FROM events WHERE chat_id = ?1 AND kind IN ({}) \
844                 ORDER BY created_at DESC, received_at DESC \
845                 LIMIT ?{} OFFSET ?{}",
846                kind_placeholders, limit_param, offset_param
847            );
848
849            let mut stmt = conn.prepare(&sql)
850                .map_err(|e| format!("Failed to prepare events query: {}", e))?;
851
852            match k.len() {
853                1 => {
854                    let rows = stmt.query_map(
855                        rusqlite::params![chat_id, k[0] as i32, limit as i64, offset as i64],
856                        parse_event_row
857                    ).map_err(|e| format!("Failed to query events: {}", e))?;
858                    rows.filter_map(|r| r.ok()).collect()
859                },
860                2 => {
861                    let rows = stmt.query_map(
862                        rusqlite::params![chat_id, k[0] as i32, k[1] as i32, limit as i64, offset as i64],
863                        parse_event_row
864                    ).map_err(|e| format!("Failed to query events: {}", e))?;
865                    rows.filter_map(|r| r.ok()).collect()
866                },
867                3 => {
868                    let rows = stmt.query_map(
869                        rusqlite::params![chat_id, k[0] as i32, k[1] as i32, k[2] as i32, limit as i64, offset as i64],
870                        parse_event_row
871                    ).map_err(|e| format!("Failed to query events: {}", e))?;
872                    rows.filter_map(|r| r.ok()).collect()
873                },
874                _ => return Err("Unsupported number of kinds".to_string()),
875            }
876        } else {
877            let mut stmt = conn.prepare(
878                "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
879                 created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata \
880                 FROM events WHERE chat_id = ?1 \
881                 ORDER BY created_at DESC, received_at DESC \
882                 LIMIT ?2 OFFSET ?3"
883            ).map_err(|e| format!("Failed to prepare events query: {}", e))?;
884
885            let rows = stmt.query_map(
886                rusqlite::params![chat_id, limit as i64, offset as i64],
887                parse_event_row
888            ).map_err(|e| format!("Failed to query events: {}", e))?;
889            rows.filter_map(|r| r.ok()).collect()
890        }
891    };
892
893    // Decrypt message content
894    let mut decrypted = Vec::with_capacity(events.len());
895    for mut event in events {
896        if event.kind == event_kind::CHAT_MESSAGE || event.kind == event_kind::PRIVATE_DIRECT_MESSAGE {
897            event.content = crate::crypto::maybe_decrypt(event.content).await
898                .unwrap_or_else(|_| "[Decryption failed]".to_string());
899        }
900        decrypted.push(event);
901    }
902
903    Ok(decrypted)
904}
905
906/// Get events that reference specific message IDs (reactions, edits).
907pub async fn get_related_events(
908    reference_ids: &[String],
909) -> Result<Vec<StoredEvent>, String> {
910    if reference_ids.is_empty() {
911        return Ok(Vec::new());
912    }
913
914    let conn = super::get_db_connection_guard_static()?;
915
916    let placeholders: String = reference_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
917    let sql = format!(
918        "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
919         created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata \
920         FROM events WHERE reference_id IN ({}) \
921         ORDER BY created_at ASC, received_at ASC",
922        placeholders
923    );
924
925    let mut stmt = conn.prepare(&sql)
926        .map_err(|e| format!("Failed to prepare related events query: {}", e))?;
927
928    let params: Vec<&dyn rusqlite::ToSql> = reference_ids.iter()
929        .map(|s| s as &dyn rusqlite::ToSql)
930        .collect();
931
932    let events: Vec<StoredEvent> = stmt.query_map(params.as_slice(), parse_event_row)
933        .map_err(|e| format!("Failed to query related events: {}", e))?
934        .filter_map(|r| r.ok())
935        .collect();
936
937    Ok(events)
938}
939
940/// Context data for a replied-to message.
941pub struct ReplyContext {
942    pub content: String,
943    pub npub: Option<String>,
944    pub has_attachment: bool,
945    /// Extension of the attachment, when the replied-to message is a file, so
946    /// the reply quote can label the type even when the target is off-screen.
947    pub extension: Option<String>,
948    /// The target's NIP-30 emoji tags — without them an off-screen quote
949    /// renders raw `:shortcodes:` and nothing ever corrects it.
950    pub emoji_tags: Vec<crate::types::EmojiTag>,
951}
952
953/// Fetch reply context for a list of message IDs.
954pub async fn get_reply_contexts(
955    message_ids: &[String],
956) -> Result<std::collections::HashMap<String, ReplyContext>, String> {
957    use std::collections::HashMap;
958
959    if message_ids.is_empty() {
960        return Ok(HashMap::new());
961    }
962
963    let (events, edits): (Vec<(String, i32, String, Option<String>, Option<String>)>, Vec<(String, String, Option<String>)>) = {
964        let conn = super::get_db_connection_guard_static()?;
965
966        let placeholders: String = (0..message_ids.len())
967            .map(|i| format!("?{}", i + 1))
968            .collect::<Vec<_>>()
969            .join(",");
970
971        // Query original messages (tags carry the file-type/name for attachment quotes)
972        let sql = format!(
973            "SELECT id, kind, content, npub, tags FROM events WHERE id IN ({})",
974            placeholders
975        );
976        let mut stmt = conn.prepare(&sql)
977            .map_err(|e| format!("Failed to prepare reply context query: {}", e))?;
978
979        let params: Vec<&str> = message_ids.iter().map(|s| s.as_str()).collect();
980        let params_dyn: Vec<&dyn rusqlite::ToSql> = params.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
981
982        let rows = stmt.query_map(params_dyn.as_slice(), |row| {
983            Ok((row.get::<_, String>(0)?, row.get::<_, i32>(1)?,
984                row.get::<_, String>(2)?, row.get::<_, Option<String>>(3)?,
985                row.get::<_, Option<String>>(4)?))
986        }).map_err(|e| format!("Failed to query reply contexts: {}", e))?;
987        let events_result: Vec<_> = rows.filter_map(|r| r.ok()).collect();
988        drop(stmt);
989
990        // Query latest edits
991        let edit_sql = format!(
992            "SELECT reference_id, content, tags FROM events \
993             WHERE kind = {} AND reference_id IN ({}) \
994             ORDER BY created_at DESC, received_at DESC",
995            event_kind::MESSAGE_EDIT, placeholders
996        );
997        let mut edit_stmt = conn.prepare(&edit_sql)
998            .map_err(|e| format!("Failed to prepare edit query: {}", e))?;
999        let edit_rows = edit_stmt.query_map(params_dyn.as_slice(), |row| {
1000            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?,
1001                row.get::<_, Option<String>>(2)?))
1002        }).map_err(|e| format!("Failed to query edits: {}", e))?;
1003        let edits_result: Vec<_> = edit_rows.filter_map(|r| r.ok()).collect();
1004
1005        (events_result, edits_result)
1006    };
1007
1008    // Build latest edit map (first = most recent since ordered DESC)
1009    let mut latest_edits: HashMap<String, (String, Option<String>)> = HashMap::new();
1010    for (ref_id, content, tags) in edits {
1011        latest_edits.entry(ref_id).or_insert((content, tags));
1012    }
1013
1014    // Batch the file replies' attachments (one query, not one per reply) for the extension below.
1015    let file_ids: Vec<String> = events.iter()
1016        .filter(|(_, kind, _, _, _)| *kind == event_kind::FILE_ATTACHMENT as i32)
1017        .map(|(id, _, _, _, _)| id.clone())
1018        .collect();
1019    let atts_by_event = super::attachments::get_attachments_for_events(&file_ids).unwrap_or_default();
1020
1021    // Decrypt and build contexts
1022    let mut contexts = HashMap::new();
1023    for (id, kind, original_content, npub, tags) in events {
1024        let has_attachment = kind == event_kind::FILE_ATTACHMENT as i32;
1025        let latest_edit = latest_edits.get(&id);
1026        let content_to_decrypt = latest_edit
1027            .map(|(c, _)| c.clone())
1028            .unwrap_or(original_content);
1029
1030        // Same rule as the message loader: an edit's tags replace the
1031        // original's, so the quote's emoji match the content it shows.
1032        let parse_emoji = |json: &Option<String>| -> Vec<crate::types::EmojiTag> {
1033            json.as_deref()
1034                .and_then(|t| serde_json::from_str::<Vec<Vec<String>>>(t).ok())
1035                .map(|parsed| crate::types::EmojiTag::extract_from_stored(&parsed))
1036                .unwrap_or_default()
1037        };
1038        let emoji_tags = match latest_edit {
1039            Some((_, edit_tags)) => parse_emoji(edit_tags),
1040            None => parse_emoji(&tags),
1041        };
1042
1043        let decrypted_content = if kind == event_kind::CHAT_MESSAGE as i32
1044            || kind == event_kind::PRIVATE_DIRECT_MESSAGE as i32
1045        {
1046            crate::crypto::maybe_decrypt(content_to_decrypt).await
1047                .unwrap_or_else(|_| "[Decryption failed]".to_string())
1048        } else {
1049            String::new()
1050        };
1051
1052        // The first attachment's extension lets the quote show the file type. From the table, with a
1053        // legacy-tag fallback for an un-backfilled pre-migration row.
1054        let extension = if has_attachment {
1055            atts_by_event.get(&id)
1056                .and_then(|atts| atts.first())
1057                .map(|a| a.extension.to_lowercase())
1058                .filter(|e| !e.is_empty())
1059                .or_else(|| tags.as_deref()
1060                    .and_then(|t| serde_json::from_str::<Vec<Vec<String>>>(t).ok())
1061                    .and_then(|parsed| parsed.into_iter()
1062                        .find(|t| t.first().map(|k| k == "attachments").unwrap_or(false))
1063                        .and_then(|t| t.into_iter().nth(1)))
1064                    .and_then(|json| serde_json::from_str::<Vec<serde_json::Value>>(&json).ok())
1065                    .and_then(|atts| atts.into_iter().next())
1066                    .and_then(|a| a.get("extension").and_then(|e| e.as_str()).map(str::to_lowercase))
1067                    .filter(|e| !e.is_empty()))
1068        } else {
1069            None
1070        };
1071
1072        contexts.insert(id, ReplyContext { content: decrypted_content, npub, has_attachment, extension, emoji_tags });
1073    }
1074
1075    Ok(contexts)
1076}
1077
1078/// Populate reply context for a PAGE of messages in one query — the sync/back-page
1079/// counterpart to [`populate_reply_context`]. Every path that emits a message straight to
1080/// the UI must resolve the quote first: the frontend renders the emitted payload, and a
1081/// reply whose parent lies outside the rendered window has no other source for it (a
1082/// parent inside the window is resolved from memory instead). Messages whose parent isn't
1083/// persisted yet are left untouched.
1084pub async fn populate_reply_contexts(messages: Vec<&mut Message>) -> Result<(), String> {
1085    let ids: Vec<String> = messages
1086        .iter()
1087        .filter(|m| !m.replied_to.is_empty())
1088        .map(|m| m.replied_to.clone())
1089        .collect();
1090    if ids.is_empty() {
1091        return Ok(());
1092    }
1093    let contexts = get_reply_contexts(&ids).await?;
1094    for message in messages {
1095        if let Some(ctx) = contexts.get(&message.replied_to) {
1096            message.replied_to_content = Some(ctx.content.clone());
1097            message.replied_to_npub = ctx.npub.clone();
1098            message.replied_to_has_attachment = Some(ctx.has_attachment);
1099            message.replied_to_attachment_extension = ctx.extension.clone();
1100            message.replied_to_emoji_tags = if ctx.emoji_tags.is_empty() { None } else { Some(ctx.emoji_tags.clone()) };
1101        }
1102    }
1103    Ok(())
1104}
1105
1106/// Populate reply context for a single message.
1107/// Used for real-time messages that don't go through get_message_views.
1108pub async fn populate_reply_context(message: &mut Message) -> Result<(), String> {
1109    if message.replied_to.is_empty() {
1110        return Ok(());
1111    }
1112
1113    let contexts = get_reply_contexts(&[message.replied_to.clone()]).await?;
1114
1115    if let Some(ctx) = contexts.get(&message.replied_to) {
1116        message.replied_to_content = Some(ctx.content.clone());
1117        message.replied_to_npub = ctx.npub.clone();
1118        message.replied_to_has_attachment = Some(ctx.has_attachment);
1119        message.replied_to_attachment_extension = ctx.extension.clone();
1120        message.replied_to_emoji_tags = if ctx.emoji_tags.is_empty() { None } else { Some(ctx.emoji_tags.clone()) };
1121    }
1122
1123    Ok(())
1124}
1125
1126/// Whether `event_id` is one of our own messages (`mine = 1`). A reply to our
1127/// own message is an implicit ping, so notifications treat it like a direct
1128/// @mention (breaks through a muted channel). Missing row → not ours → false.
1129pub fn is_own_event(event_id: &str) -> bool {
1130    let Ok(conn) = super::get_db_connection_guard_static() else {
1131        return false;
1132    };
1133    conn.query_row(
1134        "SELECT mine FROM events WHERE id = ?1",
1135        [event_id],
1136        |row| row.get::<_, i64>(0),
1137    )
1138    .map(|mine| mine == 1)
1139    .unwrap_or(false)
1140}
1141
1142// ============================================================================
1143// Message Views — compose full Messages from events + reactions + edits
1144// ============================================================================
1145
1146/// Extract a single tag value from raw tags JSON without full allocation.
1147fn extract_tag_from_json(tags_json: &str, key: &str) -> Option<String> {
1148    if tags_json.len() <= 2 { return None; }
1149    let pattern = format!("[\"{}\"", key);
1150    if !tags_json.contains(&pattern) { return None; }
1151    let tags: Vec<Vec<String>> = serde_json::from_str(tags_json).ok()?;
1152    tags.into_iter()
1153        .find(|tag| tag.first().map(|s| s.as_str()) == Some(key))
1154        .and_then(|tag| tag.into_iter().nth(1))
1155}
1156
1157
1158/// A stored reaction author written as 64-char hex (an early v2 ingest) reads
1159/// back as the npub the frontend contract expects — self-heals old rows with no
1160/// migration; a bech32 or unknown value passes through untouched.
1161fn normalize_reaction_author(author: String) -> String {
1162    if author.len() == 64 && author.bytes().all(|b| b.is_ascii_hexdigit()) {
1163        if let Ok(pk) = nostr_sdk::prelude::PublicKey::from_hex(&author) {
1164            use nostr_sdk::prelude::ToBech32;
1165            let Ok(npub) = pk.to_bech32();
1166            return npub;
1167        }
1168    }
1169    author
1170}
1171/// Extract the NIP-30 `["emoji", shortcode, url]` URL from a stored
1172/// reaction's tags. The reaction's content must be `:shortcode:` form
1173/// and the matching tag's shortcode must agree — otherwise we get the
1174/// URL of a stray emoji tag that doesn't actually represent the
1175/// reaction's chosen emoji.
1176fn extract_reaction_emoji_url(tags: &[Vec<String>], content: &str) -> Option<String> {
1177    if !content.starts_with(':') || !content.ends_with(':') || content.len() < 3 {
1178        return None;
1179    }
1180    let sc = &content[1..content.len() - 1];
1181    tags.iter().find_map(|t| {
1182        if t.len() >= 3 && t[0] == "emoji" && t[1] == sc {
1183            Some(t[2].clone())
1184        } else {
1185            None
1186        }
1187    })
1188}
1189
1190/// Extract a NIP-10 reply reference ("e" tag with "reply" marker at position 3).
1191fn extract_reply_tag_from_json(tags_json: &str) -> Option<String> {
1192    if tags_json.len() <= 2 { return None; }
1193    if !tags_json.contains("[\"e\"") { return None; }
1194    let tags: Vec<Vec<String>> = serde_json::from_str(tags_json).ok()?;
1195    tags.into_iter()
1196        .find(|tag| {
1197            tag.first().map(|s| s.as_str()) == Some("e")
1198                && tag.get(3).map(|s| s.as_str()) == Some("reply")
1199        })
1200        .and_then(|tag| tag.into_iter().nth(1))
1201}
1202
1203/// Get message events with reactions, edits, and attachments composed.
1204///
1205/// This is the main "get messages" function. Queries events, fetches related
1206/// reactions/edits, parses attachments, applies edits, resolves reply context.
1207pub async fn get_message_views(
1208    chat_id: i64,
1209    limit: usize,
1210    offset: usize,
1211) -> Result<Vec<Message>, String> {
1212    // Step 1: Get message events (kind 9, 14, 15)
1213    let message_kinds = [event_kind::CHAT_MESSAGE, event_kind::PRIVATE_DIRECT_MESSAGE, event_kind::FILE_ATTACHMENT];
1214    let message_events = get_events(chat_id, Some(&message_kinds), limit, offset).await?;
1215
1216    compose_message_views(message_events).await
1217}
1218
1219/// Compose Message views from already-fetched message events (kind 9/14/15):
1220/// fetch related reactions/edits, parse attachments, apply edits, resolve reply
1221/// context. Shared by `get_message_views` (offset pager) and `get_messages_around`
1222/// (anchored window). Input order is preserved in the output.
1223async fn compose_message_views(message_events: Vec<StoredEvent>) -> Result<Vec<Message>, String> {
1224    use std::collections::HashMap;
1225
1226    if message_events.is_empty() {
1227        return Ok(Vec::new());
1228    }
1229
1230    // Step 2: Get related events (reactions, edits)
1231    let message_ids: Vec<String> = message_events.iter().map(|e| e.id.clone()).collect();
1232    let related_events = get_related_events(&message_ids).await?;
1233
1234    let mut reactions_by_msg: HashMap<String, Vec<Reaction>> = HashMap::new();
1235    let mut edits_by_msg: HashMap<String, Vec<(u64, String, Vec<crate::types::EmojiTag>)>> = HashMap::new();
1236
1237    for event in related_events {
1238        if let Some(ref_id) = &event.reference_id {
1239            match event.kind {
1240                k if k == event_kind::REACTION => {
1241                    let emoji_url = extract_reaction_emoji_url(&event.tags, &event.content);
1242                    let reaction = Reaction {
1243                        id: event.id.clone(),
1244                        reference_id: ref_id.clone(),
1245                        author_id: normalize_reaction_author(event.npub.clone().unwrap_or_default()),
1246                        emoji: event.content.clone(),
1247                        emoji_url,
1248                    };
1249                    // Rows predating the (author, emoji) rule — or written by a client
1250                    // that never had it — must not resurrect a double count on reload.
1251                    let slot = reactions_by_msg.entry(ref_id.clone()).or_default();
1252                    if !slot.iter().any(|r| r.same_slot(&reaction)) {
1253                        slot.push(reaction);
1254                    }
1255                }
1256                k if k == event_kind::MESSAGE_EDIT => {
1257                    let decrypted = crate::crypto::maybe_decrypt(event.content.clone()).await
1258                        .unwrap_or_else(|_| event.content.clone());
1259                    let edit_emoji = crate::types::EmojiTag::extract_from_stored(&event.tags);
1260                    edits_by_msg.entry(ref_id.clone()).or_default().push((event.created_at * 1000, decrypted, edit_emoji));
1261                }
1262                _ => {}
1263            }
1264        }
1265    }
1266
1267    for edits in edits_by_msg.values_mut() {
1268        edits.sort_by_key(|(ts, _, _)| *ts);
1269    }
1270
1271    // Step 3: Attachments from the dedicated table (batched), with a legacy-tag fallback for any
1272    // file event not represented in the table (an un-backfilled pre-migration row).
1273    let attach_ids: Vec<String> = message_events.iter()
1274        .filter(|e| e.kind == event_kind::FILE_ATTACHMENT || e.kind == event_kind::CHAT_MESSAGE)
1275        .map(|e| e.id.clone())
1276        .collect();
1277    let mut attachments_by_msg = super::attachments::get_attachments_for_events(&attach_ids)
1278        .unwrap_or_default();
1279    for event in &message_events {
1280        if event.kind != event_kind::FILE_ATTACHMENT && event.kind != event_kind::CHAT_MESSAGE {
1281            continue;
1282        }
1283        if attachments_by_msg.contains_key(&event.id) {
1284            continue;
1285        }
1286        if let Some(json) = event.get_tag("attachments") {
1287            if let Ok(atts) = serde_json::from_str::<Vec<Attachment>>(json) {
1288                if !atts.is_empty() {
1289                    attachments_by_msg.insert(event.id.clone(), atts);
1290                }
1291            }
1292        }
1293    }
1294
1295    // Step 4: Compose Message structs
1296    let mut messages = Vec::with_capacity(message_events.len());
1297    for event in message_events {
1298        let replied_to = event.get_reply_reference().unwrap_or("").to_string();
1299        let at = event.timestamp_ms();
1300        let reactions = reactions_by_msg.remove(&event.id).unwrap_or_default();
1301        let attachments = attachments_by_msg.remove(&event.id).unwrap_or_default();
1302
1303        let original_content = if event.kind == event_kind::FILE_ATTACHMENT {
1304            String::new()
1305        } else {
1306            event.content.clone()
1307        };
1308
1309        // Edits carry their own emoji tags; the newest edit's tags win so the
1310        // displayed (latest) content renders its custom emoji, not the original's.
1311        let original_emoji = crate::types::EmojiTag::extract_from_stored(&event.tags);
1312        let (content, edited, edit_history, emoji_tags) = if let Some(edits) = edits_by_msg.remove(&event.id) {
1313            let mut history = Vec::with_capacity(edits.len() + 1);
1314            history.push(crate::types::EditEntry { content: original_content.clone(), edited_at: at });
1315            for (ts, c, _) in &edits {
1316                history.push(crate::types::EditEntry { content: c.clone(), edited_at: *ts });
1317            }
1318            let (latest, latest_emoji) = edits.last()
1319                .map(|(_, c, e)| (c.clone(), e.clone()))
1320                .unwrap_or_else(|| (original_content.clone(), original_emoji.clone()));
1321            (latest, true, Some(history), latest_emoji)
1322        } else {
1323            (original_content, false, None, original_emoji)
1324        };
1325
1326        let preview_metadata = event.preview_metadata
1327            .and_then(|json| serde_json::from_str(&json).ok());
1328
1329        let addressed_bots = extract_bot_tags(&event.tags);
1330        let expiration = extract_expiration_tag(&event.tags);
1331        messages.push(Message {
1332            expiration,
1333            id: event.id, content, replied_to,
1334            replied_to_content: None, replied_to_npub: None, replied_to_has_attachment: None,
1335            replied_to_attachment_extension: None, replied_to_emoji_tags: None,
1336            preview_metadata, attachments, reactions, at,
1337            pending: event.pending, failed: event.failed, mine: event.mine,
1338            npub: event.npub, wrapper_event_id: event.wrapper_event_id,
1339            edited, edit_history,
1340            emoji_tags,
1341            addressed_bots,
1342        });
1343    }
1344
1345    // Rows whose NIP-40 expiry passed while out of STATE (app closed, chat
1346    // unopened) must never reach a renderer — strip them here, at the single
1347    // DB→Message chokepoint, and purge their remnants in the background.
1348    crate::self_destruct::strip_expired(&mut messages);
1349
1350    // Step 5: Reply context
1351    let reply_ids: Vec<String> = messages.iter()
1352        .filter(|m| !m.replied_to.is_empty())
1353        .map(|m| m.replied_to.clone())
1354        .collect();
1355
1356    if !reply_ids.is_empty() {
1357        let contexts = get_reply_contexts(&reply_ids).await?;
1358        for msg in &mut messages {
1359            if let Some(ctx) = contexts.get(&msg.replied_to) {
1360                msg.replied_to_content = Some(ctx.content.clone());
1361                msg.replied_to_npub = ctx.npub.clone();
1362                msg.replied_to_has_attachment = Some(ctx.has_attachment);
1363                msg.replied_to_attachment_extension = ctx.extension.clone();
1364            }
1365        }
1366    }
1367
1368    Ok(messages)
1369}
1370
1371/// Anchored (random-access) message window: load `before` messages up to and
1372/// including the anchor, plus `after` messages strictly newer than it. O(window)
1373/// regardless of how deep the anchor sits in the chat — unlike the offset pager,
1374/// which is O(depth) to reach a far-back message.
1375///
1376/// Returns ASC by `created_at` (oldest first), composed with reactions/edits/
1377/// attachments. Errs if the anchor id isn't in the DB so the caller can fall back.
1378pub async fn get_messages_around(
1379    chat_id: i64,
1380    anchor_id: &str,
1381    before: usize,
1382    after: usize,
1383) -> Result<Vec<Message>, String> {
1384    let message_kinds = [event_kind::CHAT_MESSAGE, event_kind::PRIVATE_DIRECT_MESSAGE, event_kind::FILE_ATTACHMENT];
1385
1386    let message_events: Vec<StoredEvent> = {
1387        let conn = super::get_db_connection_guard_static()?;
1388
1389        // Resolve the anchor's FULL sort key (created_at, received_at, rowid). Paging by created_at
1390        // alone wedges on a wall of equal timestamps (a message burst): the query keeps returning the
1391        // same newest-N of the cluster, so back-paging stalls before reaching older history. The
1392        // (received_at, rowid) tiebreak — rowid being the unique final key — gives a strict total
1393        // order, so every page steps strictly past the previous, through any same-timestamp cluster.
1394        let (anchor_at, anchor_rt, anchor_rowid): (i64, i64, i64) = conn.query_row(
1395            "SELECT created_at, received_at, rowid FROM events WHERE id = ?1",
1396            rusqlite::params![anchor_id],
1397            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1398        ).map_err(|e| format!("Anchor message not found: {}", e))?;
1399
1400        // Kinds occupy ?2..?4; then ?5 created_at, ?6 received_at, ?7 rowid, ?8 limit.
1401        let kind_placeholders: String = (0..message_kinds.len())
1402            .map(|i| format!("?{}", i + 2))
1403            .collect::<Vec<_>>()
1404            .join(",");
1405        let cols = "id, kind, chat_id, user_id, content, tags, reference_id, \
1406                    created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata";
1407
1408        // Older incl. anchor: strict key <= anchor key; newest-first then reverse to ASC.
1409        let older_sql = format!(
1410            "SELECT {} FROM events WHERE chat_id = ?1 AND kind IN ({}) \
1411             AND (created_at < ?5 OR (created_at = ?5 AND (received_at < ?6 \
1412                  OR (received_at = ?6 AND rowid <= ?7)))) \
1413             ORDER BY created_at DESC, received_at DESC, rowid DESC LIMIT ?8",
1414            cols, kind_placeholders
1415        );
1416        let mut older_stmt = conn.prepare(&older_sql)
1417            .map_err(|e| format!("Failed to prepare older window query: {}", e))?;
1418        let older_rows = older_stmt.query_map(
1419            rusqlite::params![
1420                chat_id,
1421                message_kinds[0] as i32, message_kinds[1] as i32, message_kinds[2] as i32,
1422                anchor_at, anchor_rt, anchor_rowid, before as i64
1423            ],
1424            parse_event_row,
1425        ).map_err(|e| format!("Failed to query older window: {}", e))?;
1426        let mut older: Vec<StoredEvent> = older_rows.filter_map(|r| r.ok()).collect();
1427        older.reverse(); // DESC -> ASC
1428
1429        // Newer: strictly after the anchor key.
1430        let newer_sql = format!(
1431            "SELECT {} FROM events WHERE chat_id = ?1 AND kind IN ({}) \
1432             AND (created_at > ?5 OR (created_at = ?5 AND (received_at > ?6 \
1433                  OR (received_at = ?6 AND rowid > ?7)))) \
1434             ORDER BY created_at ASC, received_at ASC, rowid ASC LIMIT ?8",
1435            cols, kind_placeholders
1436        );
1437        let mut newer_stmt = conn.prepare(&newer_sql)
1438            .map_err(|e| format!("Failed to prepare newer window query: {}", e))?;
1439        let newer_rows = newer_stmt.query_map(
1440            rusqlite::params![
1441                chat_id,
1442                message_kinds[0] as i32, message_kinds[1] as i32, message_kinds[2] as i32,
1443                anchor_at, anchor_rt, anchor_rowid, after as i64
1444            ],
1445            parse_event_row,
1446        ).map_err(|e| format!("Failed to query newer window: {}", e))?;
1447        let newer: Vec<StoredEvent> = newer_rows.filter_map(|r| r.ok()).collect();
1448
1449        older.into_iter().chain(newer).collect()
1450    };
1451
1452    // Decrypt message content (mirror get_events).
1453    let mut decrypted = Vec::with_capacity(message_events.len());
1454    for mut event in message_events {
1455        if event.kind == event_kind::CHAT_MESSAGE || event.kind == event_kind::PRIVATE_DIRECT_MESSAGE {
1456            event.content = crate::crypto::maybe_decrypt(event.content).await
1457                .unwrap_or_else(|_| "[Decryption failed]".to_string());
1458        }
1459        decrypted.push(event);
1460    }
1461
1462    compose_message_views(decrypted).await
1463}
1464
1465/// Get the last message for ALL chats in a single batch query.
1466/// Optimized for app startup (chat list sidebar).
1467pub async fn get_all_chats_last_messages() -> Result<std::collections::HashMap<String, Vec<Message>>, String> {
1468    use std::collections::HashMap;
1469
1470    // Step 1: Query last message per chat via correlated subquery
1471    let message_events: Vec<(String, StoredEvent, String)> = {
1472        let conn = super::get_db_connection_guard_static()?;
1473        let mut stmt = conn.prepare(
1474            "SELECT c.chat_identifier, \
1475             e.id, e.kind, e.chat_id, e.user_id, e.content, e.tags, e.reference_id, \
1476             e.created_at, e.received_at, e.mine, e.pending, e.failed, e.wrapper_event_id, e.npub, e.preview_metadata \
1477             FROM chats c JOIN events e ON e.rowid = ( \
1478                 SELECT e2.rowid FROM events e2 WHERE e2.chat_id = c.id \
1479                 AND e2.kind IN (?1, ?2, ?3) \
1480                 ORDER BY e2.created_at DESC, e2.received_at DESC LIMIT 1) \
1481             WHERE c.chat_type != 1"
1482        ).map_err(|e| format!("Failed to prepare: {}", e))?;
1483
1484        let rows = stmt.query_map(
1485            rusqlite::params![
1486                event_kind::CHAT_MESSAGE as i32,
1487                event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1488                event_kind::FILE_ATTACHMENT as i32
1489            ],
1490            |row| {
1491                let chat_id: String = row.get(0)?;
1492                let tags_json: String = row.get(6)?;
1493                let event = StoredEvent {
1494                    id: row.get(1)?, kind: row.get::<_, i32>(2)? as u16,
1495                    chat_id: row.get(3)?, user_id: row.get(4)?, content: row.get(5)?,
1496                    tags: Vec::new(), // Deferred — parsed on-demand
1497                    reference_id: row.get(7)?,
1498                    created_at: row.get::<_, i64>(8)? as u64, received_at: row.get::<_, i64>(9)? as u64,
1499                    mine: row.get::<_, i32>(10)? != 0, pending: row.get::<_, i32>(11)? != 0,
1500                    failed: row.get::<_, i32>(12)? != 0, wrapper_event_id: row.get(13)?,
1501                    npub: row.get(14)?, preview_metadata: row.get(15)?,
1502                };
1503                Ok((chat_id, event, tags_json))
1504            }
1505        ).map_err(|e| format!("Failed to query: {}", e))?;
1506        rows.filter_map(|r| r.ok()).collect()
1507    };
1508
1509    if message_events.is_empty() {
1510        return Ok(HashMap::new());
1511    }
1512
1513    // Step 2: Related events (reactions, edits)
1514    let message_ids: Vec<String> = message_events.iter().map(|(_, e, _)| e.id.clone()).collect();
1515    let related_events = get_related_events(&message_ids).await?;
1516
1517    let mut reactions_by_msg: HashMap<String, Vec<Reaction>> = HashMap::new();
1518    let mut edits_by_msg: HashMap<String, Vec<(u64, String, Vec<crate::types::EmojiTag>)>> = HashMap::new();
1519
1520    for event in related_events {
1521        if let Some(ref_id) = &event.reference_id {
1522            match event.kind {
1523                k if k == event_kind::REACTION => {
1524                    let emoji_url = extract_reaction_emoji_url(&event.tags, &event.content);
1525                    let reaction = Reaction {
1526                        id: event.id.clone(), reference_id: ref_id.clone(),
1527                        author_id: normalize_reaction_author(event.npub.clone().unwrap_or_default()),
1528                        emoji: event.content.clone(),
1529                        emoji_url,
1530                    };
1531                    // Rows predating the (author, emoji) rule — or written by a client
1532                    // that never had it — must not resurrect a double count on reload.
1533                    let slot = reactions_by_msg.entry(ref_id.clone()).or_default();
1534                    if !slot.iter().any(|r| r.same_slot(&reaction)) {
1535                        slot.push(reaction);
1536                    }
1537                }
1538                k if k == event_kind::MESSAGE_EDIT => {
1539                    let decrypted = crate::crypto::maybe_decrypt(event.content.clone()).await
1540                        .unwrap_or_else(|_| event.content.clone());
1541                    let edit_emoji = crate::types::EmojiTag::extract_from_stored(&event.tags);
1542                    edits_by_msg.entry(ref_id.clone()).or_default().push((event.created_at * 1000, decrypted, edit_emoji));
1543                }
1544                _ => {}
1545            }
1546        }
1547    }
1548    for edits in edits_by_msg.values_mut() {
1549        edits.sort_by_key(|(ts, _, _)| *ts);
1550    }
1551
1552    // Step 3: Attachments from the dedicated table (batched), with a legacy-tag fallback.
1553    let attach_ids: Vec<String> = message_events.iter()
1554        .filter(|(_, e, _)| e.kind == event_kind::FILE_ATTACHMENT || e.kind == event_kind::CHAT_MESSAGE)
1555        .map(|(_, e, _)| e.id.clone())
1556        .collect();
1557    let mut attachments_by_msg = super::attachments::get_attachments_for_events(&attach_ids)
1558        .unwrap_or_default();
1559    for (_, event, tags_json) in &message_events {
1560        if event.kind != event_kind::FILE_ATTACHMENT && event.kind != event_kind::CHAT_MESSAGE {
1561            continue;
1562        }
1563        if attachments_by_msg.contains_key(&event.id) {
1564            continue;
1565        }
1566        if let Some(val) = extract_tag_from_json(tags_json, "attachments") {
1567            if let Ok(atts) = serde_json::from_str::<Vec<Attachment>>(&val) {
1568                if !atts.is_empty() {
1569                    attachments_by_msg.insert(event.id.clone(), atts);
1570                }
1571            }
1572        }
1573    }
1574
1575    // Step 4: Compose Messages grouped by chat_identifier
1576    let mut result: HashMap<String, Vec<Message>> = HashMap::new();
1577
1578    for (chat_identifier, event, tags_json) in message_events {
1579        let reactions = reactions_by_msg.remove(&event.id).unwrap_or_default();
1580        let attachments = attachments_by_msg.remove(&event.id).unwrap_or_default();
1581        let replied_to = extract_reply_tag_from_json(&tags_json).unwrap_or_default();
1582
1583        // Decrypt content
1584        let original_content = if event.kind == event_kind::CHAT_MESSAGE
1585            || event.kind == event_kind::PRIVATE_DIRECT_MESSAGE
1586        {
1587            crate::crypto::maybe_decrypt(event.content.clone()).await
1588                .unwrap_or_else(|_| "[Decryption failed]".to_string())
1589        } else {
1590            String::new()
1591        };
1592
1593        let stored_tags = serde_json::from_str::<Vec<Vec<String>>>(&tags_json).unwrap_or_default();
1594        let original_emoji = crate::types::EmojiTag::extract_from_stored(&stored_tags);
1595        let addressed_bots = extract_bot_tags(&stored_tags);
1596        let expiration = extract_expiration_tag(&stored_tags);
1597        // Newest edit's emoji tags win so the latest content renders correctly.
1598        let (content, edited, edit_history, emoji_tags) = if let Some(edits) = edits_by_msg.remove(&event.id) {
1599            let (latest, latest_emoji) = edits.last()
1600                .map(|(_, c, e)| (c.clone(), e.clone()))
1601                .unwrap_or_else(|| (original_content.clone(), original_emoji.clone()));
1602            let history: Vec<crate::types::EditEntry> = std::iter::once(crate::types::EditEntry {
1603                content: original_content, edited_at: event.created_at * 1000,
1604            }).chain(edits.into_iter().map(|(ts, c, _)| crate::types::EditEntry { content: c, edited_at: ts }))
1605            .collect();
1606            (latest, true, Some(history), latest_emoji)
1607        } else {
1608            (original_content, false, None, original_emoji)
1609        };
1610
1611        let preview_metadata = event.preview_metadata
1612            .and_then(|json| serde_json::from_str(&json).ok());
1613
1614        result.entry(chat_identifier).or_default().push(Message {
1615            expiration,
1616            id: event.id, content, replied_to,
1617            replied_to_content: None, replied_to_npub: None, replied_to_has_attachment: None,
1618            replied_to_attachment_extension: None, replied_to_emoji_tags: None,
1619            preview_metadata, attachments, reactions, at: event.created_at * 1000,
1620            pending: event.pending, failed: event.failed, mine: event.mine,
1621            npub: event.npub, wrapper_event_id: event.wrapper_event_id,
1622            edited, edit_history,
1623            emoji_tags,
1624            addressed_bots,
1625        });
1626    }
1627
1628    // Step 5: Reply context — the openChat pre-paint renders this boot last-message
1629    // synchronously (before the richer get_message_views load lands), so without
1630    // context here a reply shows its quote only on the second open.
1631    let reply_ids: Vec<String> = result.values()
1632        .flatten()
1633        .filter(|m| !m.replied_to.is_empty())
1634        .map(|m| m.replied_to.clone())
1635        .collect();
1636
1637    if !reply_ids.is_empty() {
1638        let contexts = get_reply_contexts(&reply_ids).await?;
1639        for msg in result.values_mut().flatten() {
1640            if let Some(ctx) = contexts.get(&msg.replied_to) {
1641                msg.replied_to_content = Some(ctx.content.clone());
1642                msg.replied_to_npub = ctx.npub.clone();
1643                msg.replied_to_has_attachment = Some(ctx.has_attachment);
1644                msg.replied_to_attachment_extension = ctx.extension.clone();
1645            }
1646        }
1647    }
1648
1649    // An expired self-destruct as a chat's last message must not flash in the
1650    // chat list — strip + background-purge; the preview shows empty until the
1651    // next boot resolves the prior message, same as a mid-session sweep.
1652    for msgs in result.values_mut() {
1653        crate::self_destruct::strip_expired(msgs);
1654    }
1655
1656    Ok(result)
1657}
1658
1659/// Per-chat unread count, computed straight from the DB so it's correct even when only the last
1660/// message is in RAM (the boot state). Mirrors the in-memory walk-back exactly: unread = non-mine
1661/// messages newer than the most recent "anchor" (our own message OR the `last_read` marker,
1662/// whichever is latest). A never-read chat (empty `last_read`, no own message) counts all its
1663/// non-mine messages. Returns `chat_identifier → count`; chats with 0 unread are omitted.
1664/// CHAT-level muted/blocked filtering is left to the caller (it lives in RAM state, cheaply).
1665/// SENDER-level filtering happens here: a message whose author's DM is muted or whose profile
1666/// is blocked never counts, in any chat — muting a person silences their community messages too.
1667pub async fn unread_counts() -> Result<std::collections::HashMap<String, u32>, String> {
1668    let conn = super::get_db_connection_guard_static()?;
1669    // Anchor computed once per chat in the CTE so the count scan doesn't re-derive it per row. The
1670    // anchor filter rides the LEFT JOIN's ON clause so a never-read chat still yields a row (anchor
1671    // 0 via COALESCE) and counts all its messages; in WHERE it would drop those chats. The
1672    // `last_read` anchor is kind-agnostic: a "read to here" marker can land on a system event (kind
1673    // 30078) and must still cut the count by its timestamp. Only the own-message anchor is kind-filtered.
1674    let mut stmt = conn
1675        .prepare(
1676            "WITH anchors AS ( \
1677                SELECT c.id AS chat_id, c.chat_identifier AS chat_identifier, \
1678                       COALESCE(MAX(e.created_at), 0) AS anchor_ts \
1679                FROM chats c \
1680                LEFT JOIN events e ON e.chat_id = c.id \
1681                  AND ((e.mine = 1 AND e.kind IN (?1, ?2, ?3)) OR e.id = c.last_read) \
1682                GROUP BY c.id \
1683             ) \
1684             SELECT a.chat_identifier, COUNT(*) AS unread \
1685             FROM events e JOIN anchors a ON a.chat_id = e.chat_id \
1686             WHERE e.kind IN (?1, ?2, ?3) AND e.mine = 0 AND e.created_at > a.anchor_ts \
1687               AND (e.npub IS NULL OR e.npub NOT IN ( \
1688                     SELECT chat_identifier FROM chats WHERE muted = 1 \
1689                     UNION \
1690                     SELECT npub FROM profiles WHERE is_blocked = 1)) \
1691             GROUP BY a.chat_identifier",
1692        )
1693        .map_err(|e| format!("prepare unread_counts: {e}"))?;
1694    let rows = stmt
1695        .query_map(
1696            rusqlite::params![
1697                event_kind::CHAT_MESSAGE as i32,
1698                event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1699                event_kind::FILE_ATTACHMENT as i32
1700            ],
1701            |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u32)),
1702        )
1703        .map_err(|e| format!("query unread_counts: {e}"))?;
1704    let mut out = std::collections::HashMap::new();
1705    for r in rows.flatten() {
1706        out.insert(r.0, r.1);
1707    }
1708    Ok(out)
1709}
1710
1711/// Unread count for a SINGLE chat, same semantics as [`unread_counts`]. The RAM cache calls this to
1712/// reconcile one chat (open / delete / retreat) without recomputing every chat's count.
1713pub async fn unread_count_for_chat(chat_identifier: &str) -> Result<u32, String> {
1714    let conn = super::get_db_connection_guard_static()?;
1715    let count: i64 = conn
1716        .query_row(
1717            "SELECT COUNT(*) FROM events e JOIN chats c ON e.chat_id = c.id \
1718             WHERE c.chat_identifier = ?4 AND e.kind IN (?1, ?2, ?3) AND e.mine = 0 \
1719               AND (e.npub IS NULL OR e.npub NOT IN ( \
1720                     SELECT chat_identifier FROM chats WHERE muted = 1 \
1721                     UNION \
1722                     SELECT npub FROM profiles WHERE is_blocked = 1)) \
1723               AND e.created_at > COALESCE(( \
1724                     SELECT MAX(e2.created_at) FROM events e2 \
1725                     WHERE e2.chat_id = c.id \
1726                       AND ((e2.mine = 1 AND e2.kind IN (?1, ?2, ?3)) OR e2.id = c.last_read)), 0)",
1727            rusqlite::params![
1728                event_kind::CHAT_MESSAGE as i32,
1729                event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1730                event_kind::FILE_ATTACHMENT as i32,
1731                chat_identifier
1732            ],
1733            |row| row.get(0),
1734        )
1735        .map_err(|e| format!("query unread_count_for_chat: {e}"))?;
1736    Ok(count as u32)
1737}
1738
1739/// What [`compute_unread_anchor`] decided a chat's read marker should become to surface its newest
1740/// contact message as unread. Computed from the full DB history (RAM may hold only a preview
1741/// message for an unopened community).
1742#[derive(Debug, PartialEq)]
1743pub enum UnreadMark {
1744    /// Nothing to surface: no contact message, or a strictly-newer own message (we spoke last).
1745    NoOp,
1746    /// Reset to the never-read anchor: the target is the chat's earliest message.
1747    Clear,
1748    /// Retreat `last_read` to this event id (the newest message in a strictly earlier second).
1749    Anchor(String),
1750}
1751
1752/// Decide how to mark `chat_identifier` unread. Anchors on the newest message strictly before the
1753/// newest contact message's second — the count query compares whole seconds with a strict `>`, so a
1754/// same-second anchor would leave the target on the boundary and it would read as caught-up.
1755pub async fn compute_unread_anchor(chat_identifier: &str) -> Result<UnreadMark, String> {
1756    let conn = super::get_db_connection_guard_static()?;
1757    let (k0, k1, k2) = (
1758        event_kind::CHAT_MESSAGE as i32,
1759        event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1760        event_kind::FILE_ATTACHMENT as i32,
1761    );
1762    // Newest non-mine message second (the target) and newest overall (to detect we spoke last).
1763    let (target_ts, newest_ts): (Option<i64>, Option<i64>) = conn
1764        .query_row(
1765            "SELECT MAX(CASE WHEN e.mine = 0 THEN e.created_at END), MAX(e.created_at) \
1766             FROM events e JOIN chats c ON e.chat_id = c.id \
1767             WHERE c.chat_identifier = ?1 AND e.kind IN (?2, ?3, ?4)",
1768            rusqlite::params![chat_identifier, k0, k1, k2],
1769            |row| Ok((row.get(0)?, row.get(1)?)),
1770        )
1771        .map_err(|e| format!("unread anchor target: {e}"))?;
1772
1773    let target_ts = match target_ts {
1774        Some(t) => t,
1775        None => return Ok(UnreadMark::NoOp), // no contact message to surface
1776    };
1777    if newest_ts.map_or(false, |n| n > target_ts) {
1778        return Ok(UnreadMark::NoOp); // a strictly-newer own message → we spoke last
1779    }
1780
1781    let anchor_id: Option<String> = conn
1782        .query_row(
1783            "SELECT e.id FROM events e JOIN chats c ON e.chat_id = c.id \
1784             WHERE c.chat_identifier = ?1 AND e.kind IN (?2, ?3, ?4) AND e.created_at < ?5 \
1785             ORDER BY e.created_at DESC LIMIT 1",
1786            rusqlite::params![chat_identifier, k0, k1, k2, target_ts],
1787            |row| row.get(0),
1788        )
1789        .optional()
1790        .map_err(|e| format!("unread anchor prev: {e}"))?;
1791
1792    Ok(match anchor_id {
1793        Some(id) => UnreadMark::Anchor(id),
1794        None => UnreadMark::Clear,
1795    })
1796}
1797
1798/// Drain a sync loop's pending message batch into one transaction. The shared flush for the
1799/// segment-flush pattern: bulk loops COLLECT message saves and call this at delete barriers +
1800/// loop end (a batched save committing AFTER a delete it originally preceded would resurrect
1801/// the deleted row — flushing first preserves wire order). Session-guarded HERE so every bulk
1802/// path gets the same swap-safety: on a stale session the batch is DROPPED, never written
1803/// into the next account's DB (the caller's loop is about to bail anyway).
1804pub async fn flush_message_batch(
1805    chat_id: &str,
1806    pending: &mut Vec<&Message>,
1807    session: &std::sync::Arc<crate::db::Session>,
1808) {
1809    crate::db::scoped(async move {
1810        if pending.is_empty() {
1811            return;
1812        }
1813        if !session.is_live() {
1814            pending.clear();
1815            return;
1816        }
1817        if let Err(e) = save_messages_batch(chat_id, pending).await {
1818            crate::log_warn!("[DB] batch flush failed for {}: {}", chat_id, e);
1819        }
1820        pending.clear();
1821    })
1822    .await
1823}
1824
1825/// Batch save messages for a chat — one transaction for the whole slice.
1826pub async fn save_chat_messages(chat_id: &str, messages: &[Message]) -> Result<(), String> {
1827    if messages.is_empty() {
1828        return Ok(());
1829    }
1830    let refs: Vec<&Message> = messages.iter().collect();
1831    save_messages_batch(chat_id, &refs).await.map(|_| ())
1832}
1833
1834#[cfg(test)]
1835mod tests {
1836    use super::*;
1837    use crate::stored_event::SystemEventType;
1838
1839    static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(71000);
1840
1841    fn make_test_npub(n: u32) -> String {
1842        const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
1843        let mut payload = vec![b'q'; 58];
1844        let mut x = n as u64;
1845        let mut i = 58;
1846        while x > 0 && i > 0 {
1847            i -= 1;
1848            payload[i] = BECH32[(x as usize) % 32];
1849            x /= 32;
1850        }
1851        format!("npub1{}", std::str::from_utf8(&payload).unwrap())
1852    }
1853
1854    fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
1855        let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1856        crate::db::close_database();
1857        // Each test rebinds to a fresh per-account DB; the row-id caches are per-account, so a stale
1858        // entry (e.g. a shared author npub) would point into the prior test's DB and FK-fail the insert.
1859        crate::db::clear_id_caches();
1860        let tmp = tempfile::tempdir().unwrap();
1861        let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1862        let account = make_test_npub(n);
1863        std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
1864        crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
1865        crate::db::set_current_account(account.clone()).unwrap();
1866        crate::db::init_database(&account).unwrap();
1867        (tmp, guard)
1868    }
1869
1870    fn now_secs() -> u64 {
1871        std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
1872    }
1873
1874    /// A page of synced replies must come out of the batch resolver carrying their quotes.
1875    /// The sync/promote paths emit straight to the UI, and the renderer falls back to the
1876    /// in-memory parent only when the parent is inside the rendered window — so a reply to
1877    /// an OLDER message renders with no quote at all unless the backend resolved it here.
1878    /// Reopening the chat re-reads through `get_message_views` (which resolves), which is
1879    /// why the bug looked like "replies lose their context until you reopen".
1880    #[tokio::test]
1881    async fn batch_resolver_fills_quotes_for_a_synced_page() {
1882        let (_tmp, _guard) = init_test_db();
1883        let chat = "channel_reply_batch";
1884        let author = make_test_npub(90_001);
1885
1886        // An older, already-persisted parent (the case with no in-memory fallback).
1887        let mut parent = Message::default();
1888        parent.id = "parent_evt".to_string();
1889        parent.content = "the original message".to_string();
1890        parent.npub = Some(author.clone());
1891        parent.at = now_secs() - 5_000;
1892        save_message(chat, &parent).await.unwrap();
1893
1894        // The freshly-synced page: a reply to it, plus an unrelated message.
1895        let mut reply = Message::default();
1896        reply.id = "reply_evt".to_string();
1897        reply.content = "replying now".to_string();
1898        reply.replied_to = "parent_evt".to_string();
1899        reply.at = now_secs();
1900        let mut plain = Message::default();
1901        plain.id = "plain_evt".to_string();
1902        plain.content = "unrelated".to_string();
1903        plain.at = now_secs();
1904        // A reply whose parent this device has never seen must stay empty, not fabricate a quote.
1905        let mut orphan = Message::default();
1906        orphan.id = "orphan_evt".to_string();
1907        orphan.replied_to = "never_seen_evt".to_string();
1908        orphan.at = now_secs();
1909
1910        populate_reply_contexts(vec![&mut reply, &mut plain, &mut orphan]).await.unwrap();
1911
1912        assert_eq!(
1913            reply.replied_to_content.as_deref(),
1914            Some("the original message"),
1915            "the synced reply carries its parent's content"
1916        );
1917        assert_eq!(reply.replied_to_npub.as_deref(), Some(author.as_str()), "and its parent's author");
1918        assert!(plain.replied_to_content.is_none(), "a non-reply is untouched");
1919        assert!(orphan.replied_to_content.is_none(), "an unresolvable parent leaves the quote empty");
1920    }
1921
1922    /// An empty page (or one with no replies at all) must not query or error — the sync path
1923    /// calls this for every page, most of which carry no replies.
1924    #[tokio::test]
1925    async fn batch_resolver_is_a_noop_without_replies() {
1926        let (_tmp, _guard) = init_test_db();
1927        populate_reply_contexts(vec![]).await.unwrap();
1928        let mut plain = Message::default();
1929        plain.id = "solo".to_string();
1930        populate_reply_contexts(vec![&mut plain]).await.unwrap();
1931        assert!(plain.replied_to_content.is_none());
1932    }
1933
1934    // C-H2: a presence (join/leave) persisted from HISTORY must keep its authenticated timestamp so it
1935    // sorts where it happened, not at ingest-time "now"; a future-dated one is clamped so it can't jump
1936    // ahead of real activity.
1937    #[tokio::test]
1938    async fn system_event_stamps_authenticated_time_clamped_to_now() {
1939        let (_tmp, _guard) = init_test_db();
1940        let chat = "channel_ch2_timestamp";
1941        let before = now_secs();
1942        let past = before - 100_000;
1943
1944        save_system_event_at("ev_past", chat, SystemEventType::MemberJoined, "npubX", None, past, None, None).await.unwrap();
1945        save_system_event_at("ev_future", chat, SystemEventType::MemberJoined, "npubX", None, before + 100_000, None, None).await.unwrap();
1946        let after = now_secs();
1947
1948        let evs = get_system_events_for_chat(chat).unwrap();
1949        let past_ev = evs.iter().find(|e| e.id == "ev_past").expect("past event saved");
1950        assert_eq!(past_ev.created_at, past, "historical join keeps its real (authenticated) timestamp");
1951
1952        let fut_ev = evs.iter().find(|e| e.id == "ev_future").expect("future event saved");
1953        assert!(fut_ev.created_at >= before && fut_ev.created_at <= after,
1954            "future-dated event clamped to local now ({} not in {}..={})", fut_ev.created_at, before, after);
1955    }
1956
1957    // Delete-affordance resolution must work on paged-out rows: the events table is the
1958    // fallback source for (chat, mine, author) when a message isn't STATE-resident.
1959    #[tokio::test]
1960    async fn event_delete_context_resolves_from_db() {
1961        let (_tmp, _guard) = init_test_db();
1962        let chat = "npub1contactdc";
1963
1964        let mine_msg = Message { id: "dc_mine".into(), content: "x".into(), at: 1_000, mine: true, ..Default::default() };
1965        let theirs = Message {
1966            id: "dc_theirs".into(), content: "y".into(), at: 2_000, mine: false,
1967            npub: Some("npub1sender".to_string()),
1968            ..Default::default()
1969        };
1970        save_message(chat, &mine_msg).await.unwrap();
1971        save_message(chat, &theirs).await.unwrap();
1972
1973        let (chat_id, mine, _author) = event_delete_context("dc_mine").unwrap().expect("own row resolves");
1974        assert_eq!(chat_id, chat);
1975        assert!(mine);
1976
1977        let (chat_id, mine, author) = event_delete_context("dc_theirs").unwrap().expect("contact row resolves");
1978        assert_eq!(chat_id, chat);
1979        assert!(!mine);
1980        assert_eq!(author.as_deref(), Some("npub1sender"));
1981
1982        assert!(event_delete_context("dc_absent").unwrap().is_none(), "unknown id is None, not an error");
1983    }
1984
1985    // The reported bug: unread must accumulate across a restart (when only the last message per
1986    // chat is in RAM). The DB cutoff count must mirror the in-memory walk-back exactly.
1987    #[tokio::test]
1988    async fn unread_counts_match_walk_back_semantics() {
1989        let (_tmp, _guard) = init_test_db();
1990        let chat = "npub1contactdm";
1991        // `at` is ms (created_at = at/1000); use distinct seconds.
1992        let mk = |id: &str, secs: u64, mine: bool| Message {
1993            id: id.into(), content: "x".into(), at: secs * 1000, mine,
1994            npub: (!mine).then(|| "npub1sender".to_string()),
1995            ..Default::default()
1996        };
1997        let unread = || async { unread_counts().await.unwrap().get(chat).copied().unwrap_or(0) };
1998
1999        // 6 contact messages, never opened/read, no own reply → all 6 unread.
2000        for i in 0..6u64 {
2001            save_message(chat, &mk(&format!("m{i}"), 1000 + i, false)).await.unwrap();
2002        }
2003        assert_eq!(unread().await, 6, "never-read backlog counts all 6");
2004
2005        // 2 more arrive → 8, NOT replaced by 2 (the exact reported symptom).
2006        save_message(chat, &mk("m6", 2000, false)).await.unwrap();
2007        save_message(chat, &mk("m7", 2001, false)).await.unwrap();
2008        assert_eq!(unread().await, 8, "6 backlog + 2 new = 8");
2009
2010        // Our own reply clears it (walk-back stops at the newest mine).
2011        save_message(chat, &mk("mine", 2002, true)).await.unwrap();
2012        assert_eq!(unread().await, 0, "own message = read up to here");
2013
2014        // A contact message after our send is unread again.
2015        save_message(chat, &mk("m8", 2003, false)).await.unwrap();
2016        assert_eq!(unread().await, 1, "one new after our send");
2017
2018        // last_read marker advances the cutoff just like an own message.
2019        {
2020            let conn = crate::db::get_write_connection_guard_static().unwrap();
2021            conn.execute(
2022                "UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
2023                rusqlite::params!["m8", chat],
2024            ).unwrap();
2025        }
2026        assert_eq!(unread().await, 0, "last_read=m8 clears all");
2027        save_message(chat, &mk("m9", 2004, false)).await.unwrap();
2028        assert_eq!(unread().await, 1, "one arrival after last_read");
2029    }
2030
2031    // Muting a person (their DM row) or blocking them silences their messages in EVERY chat's
2032    // count — community messages from a muted sender must not badge.
2033    #[tokio::test]
2034    async fn unread_counts_exclude_muted_and_blocked_senders() {
2035        let (_tmp, _guard) = init_test_db();
2036        let chat = "channel_mute_exclusion";
2037        let mk = |id: &str, secs: u64, npub: &str| Message {
2038            id: id.into(), content: "x".into(), at: secs * 1000, mine: false,
2039            npub: Some(npub.to_string()),
2040            ..Default::default()
2041        };
2042        save_message(chat, &mk("n1", 1000, "npub1noisy")).await.unwrap();
2043        save_message(chat, &mk("q1", 1001, "npub1quiet")).await.unwrap();
2044
2045        let map = || async { unread_counts().await.unwrap().get(chat).copied().unwrap_or(0) };
2046        let one = || async { unread_count_for_chat(chat).await.unwrap() };
2047        assert_eq!(map().await, 2, "both senders count while neither is muted");
2048        assert_eq!(one().await, 2);
2049
2050        // Mute npub1noisy: their (message-less) DM row exists purely to carry the flag.
2051        {
2052            let conn = crate::db::get_write_connection_guard_static().unwrap();
2053            conn.execute(
2054                "INSERT INTO chats (chat_identifier, chat_type, participants, created_at, muted) \
2055                 VALUES ('npub1noisy', 0, '', 1000, 1)",
2056                [],
2057            ).unwrap();
2058        }
2059        assert_eq!(map().await, 1, "muted sender's message stops counting");
2060        assert_eq!(one().await, 1);
2061
2062        // Block npub1quiet: same exclusion via the profiles flag.
2063        {
2064            let conn = crate::db::get_write_connection_guard_static().unwrap();
2065            conn.execute(
2066                "INSERT INTO profiles (npub, is_blocked) VALUES ('npub1quiet', 1) \
2067                 ON CONFLICT(npub) DO UPDATE SET is_blocked = 1",
2068                [],
2069            ).unwrap();
2070        }
2071        assert_eq!(map().await, 0, "blocked sender's message stops counting too");
2072        assert_eq!(one().await, 0);
2073
2074        // Unmute → the count comes straight back (nothing was consumed).
2075        {
2076            let conn = crate::db::get_write_connection_guard_static().unwrap();
2077            conn.execute("UPDATE chats SET muted = 0 WHERE chat_identifier = 'npub1noisy'", []).unwrap();
2078        }
2079        assert_eq!(map().await, 1, "unmuting restores the sender's count");
2080        assert_eq!(one().await, 1);
2081    }
2082
2083    // The single-chat reconcile query must agree with the full map for every state the cache
2084    // reconciles from (never-read, own-reply cutoff, last_read marker).
2085    #[tokio::test]
2086    async fn unread_count_for_chat_matches_the_map() {
2087        let (_tmp, _guard) = init_test_db();
2088        let chat = "npub1reconcile";
2089        let mk = |id: &str, secs: u64, mine: bool| Message {
2090            id: id.into(), content: "x".into(), at: secs * 1000, mine,
2091            npub: (!mine).then(|| "npub1sender".to_string()),
2092            ..Default::default()
2093        };
2094        let agree = || async {
2095            let map = unread_counts().await.unwrap().get(chat).copied().unwrap_or(0);
2096            let one = unread_count_for_chat(chat).await.unwrap();
2097            assert_eq!(map, one, "single-chat query diverged from the map");
2098            one
2099        };
2100
2101        for i in 0..4u64 { save_message(chat, &mk(&format!("m{i}"), 1000 + i, false)).await.unwrap(); }
2102        assert_eq!(agree().await, 4);
2103        save_message(chat, &mk("mine", 1010, true)).await.unwrap();
2104        assert_eq!(agree().await, 0);
2105        save_message(chat, &mk("after", 1011, false)).await.unwrap();
2106        assert_eq!(agree().await, 1);
2107        {
2108            let conn = crate::db::get_write_connection_guard_static().unwrap();
2109            conn.execute("UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
2110                rusqlite::params!["after", chat]).unwrap();
2111        }
2112        assert_eq!(agree().await, 0);
2113        // A chat with nothing at all reconciles to 0 (no row in the map).
2114        assert_eq!(unread_count_for_chat("npub1nonexistent").await.unwrap(), 0);
2115    }
2116
2117    #[tokio::test]
2118    async fn attachments_table_round_trip_dedup_and_cascade() {
2119        let (_tmp, _guard) = init_test_db();
2120        // downloaded:false mirrors a freshly-received attachment (Attachment::default is downloaded:true).
2121        let att = |id: &str, name: &str| Attachment {
2122            id: id.into(), url: format!("https://blossom/{id}"), name: name.into(),
2123            extension: "png".into(), size: 42, downloaded: false, ..Default::default()
2124        };
2125        let msg = |mid: &str, secs: u64, atts: Vec<Attachment>| Message {
2126            id: mid.into(), content: String::new(), at: secs * 1000, mine: false,
2127            npub: Some("npub1sender".into()), attachments: atts, ..Default::default()
2128        };
2129
2130        // Save a message with two attachments → both rows, order preserved by att_index.
2131        save_message("npub1att", &msg("m1", 1000, vec![att("hashA", "a.png"), att("hashB", "b.png")])).await.unwrap();
2132        let got = crate::db::attachments::get_attachments_for_event("m1").unwrap();
2133        assert_eq!(got.len(), 2);
2134        assert_eq!((got[0].id.as_str(), got[1].id.as_str()), ("hashA", "hashB"), "att_index order");
2135        assert_eq!(got[0].name, "a.png");
2136        assert_eq!(got[0].size, 42);
2137        assert!(!got[0].downloaded);
2138
2139        // Single-row download flip (no read-modify-write of a blob).
2140        crate::db::attachments::set_attachment_downloaded("m1", "hashA", true, "/tmp/a.png").unwrap();
2141        let got = crate::db::attachments::get_attachments_for_event("m1").unwrap();
2142        assert!(got[0].downloaded && got[0].path == "/tmp/a.png");
2143        assert!(!got[1].downloaded, "sibling attachment untouched");
2144
2145        // Dedup: a second message shares hashA → backfill-by-hash marks it (indexed, no LIKE scan).
2146        save_message("npub1att", &msg("m2", 1001, vec![att("hashA", "a-again.png")])).await.unwrap();
2147        let affected = crate::db::attachments::backfill_downloaded_by_hash("hashA", "/tmp/a.png", "m1").unwrap();
2148        assert_eq!(affected, vec!["m2".to_string()]);
2149        assert!(crate::db::attachments::get_attachments_for_event("m2").unwrap()[0].downloaded);
2150
2151        // The write funnel dual-populates the legacy tag, so get_message_views (still tag-backed
2152        // during shadow-populate) composes the attachments onto the Message.
2153        let chat_int = crate::db::id_cache::get_or_create_chat_id("npub1att").unwrap();
2154        let views = get_message_views(chat_int, 10, 0).await.unwrap();
2155        let m1 = views.iter().find(|m| m.id == "m1").unwrap();
2156        assert_eq!(m1.attachments.len(), 2);
2157
2158        // Cascade: deleting the event removes its attachment rows.
2159        delete_event("m1").await.unwrap();
2160        assert!(crate::db::attachments::get_attachments_for_event("m1").unwrap().is_empty(), "ON DELETE CASCADE");
2161    }
2162
2163    // The download persist path: a re-save must never DOWNGRADE download state (relay re-delivery),
2164    // but a re-save carrying a completed download (+ the nonce→content-hash id rewrite) must persist.
2165    #[tokio::test]
2166    async fn attachment_download_state_is_monotonic_across_resaves() {
2167        let (_tmp, _guard) = init_test_db();
2168        let att = |id: &str, downloaded: bool, path: &str| Attachment {
2169            id: id.into(), url: "u".into(), name: "f.png".into(), extension: "png".into(),
2170            size: 1, downloaded, path: path.into(), ..Default::default()
2171        };
2172        let msg = |atts: Vec<Attachment>| Message {
2173            id: "dl1".into(), content: String::new(), at: 1_000_000, mine: false,
2174            npub: Some("npub1s".into()), attachments: atts, ..Default::default()
2175        };
2176
2177        // Receive (not downloaded), then the user downloads → single-row flip.
2178        save_message("npub1dl", &msg(vec![att("nonceid", false, "")])).await.unwrap();
2179        crate::db::attachments::set_attachment_downloaded("dl1", "nonceid", true, "/tmp/f.png").unwrap();
2180
2181        // Relay re-delivery (downloaded=false) must NOT reset the download.
2182        save_message("npub1dl", &msg(vec![att("nonceid", false, "")])).await.unwrap();
2183        let got = crate::db::attachments::get_attachments_for_event("dl1").unwrap();
2184        assert!(got[0].downloaded && got[0].path == "/tmp/f.png", "re-delivery preserves the download");
2185
2186        // Post-download re-save: id rewritten nonce→content-hash, downloaded=true persists in one pass.
2187        save_message("npub1dl", &msg(vec![att("contenthash", true, "/tmp/f.png")])).await.unwrap();
2188        let got = crate::db::attachments::get_attachments_for_event("dl1").unwrap();
2189        assert_eq!(got[0].id, "contenthash", "hash rewritten nonce→content");
2190        assert!(got[0].downloaded && got[0].path == "/tmp/f.png");
2191
2192        // A re-delivery AFTER the rewrite must keep the content-hash key (not revert to the nonce),
2193        // so the hash-keyed download/backfill/clear helpers still resolve the row.
2194        save_message("npub1dl", &msg(vec![att("nonceid", false, "")])).await.unwrap();
2195        let got = crate::db::attachments::get_attachments_for_event("dl1").unwrap();
2196        assert_eq!(got[0].id, "contenthash", "content-hash key survives a later re-delivery");
2197        assert!(got[0].downloaded && got[0].path == "/tmp/f.png");
2198    }
2199
2200    // An un-backfilled pre-migration event (attachments only in the legacy tag, no table row) still
2201    // renders via the read fallback.
2202    #[tokio::test]
2203    async fn attachments_fall_back_to_legacy_tag_when_table_empty() {
2204        let (_tmp, _guard) = init_test_db();
2205        let a = Attachment {
2206            id: "tagonly".into(), url: "u".into(), name: "old.png".into(), extension: "png".into(),
2207            size: 7, downloaded: false, ..Default::default()
2208        };
2209        save_message("npub1old", &Message {
2210            id: "old1".into(), content: String::new(), at: 2_000_000, mine: false,
2211            npub: Some("npub1s".into()), attachments: vec![a.clone()], ..Default::default()
2212        }).await.unwrap();
2213
2214        // Simulate the pre-migration shape: drop the table rows, write the legacy tag onto the event.
2215        {
2216            let conn = crate::db::get_write_connection_guard_static().unwrap();
2217            conn.execute("DELETE FROM attachments WHERE event_id='old1'", []).unwrap();
2218            let inner = serde_json::to_string(&vec![a]).unwrap();
2219            let tags = serde_json::to_string(&vec![vec!["attachments".to_string(), inner]]).unwrap();
2220            conn.execute("UPDATE events SET tags=?1 WHERE id='old1'", rusqlite::params![tags]).unwrap();
2221        }
2222        assert!(crate::db::attachments::get_attachments_for_event("old1").unwrap().is_empty(), "table empty");
2223
2224        let chat_int = crate::db::id_cache::get_or_create_chat_id("npub1old").unwrap();
2225        let views = get_message_views(chat_int, 10, 0).await.unwrap();
2226        let old = views.iter().find(|m| m.id == "old1").unwrap();
2227        assert_eq!(old.attachments.len(), 1, "attachment served from the legacy-tag fallback");
2228        assert_eq!(old.attachments[0].name, "old.png");
2229    }
2230
2231    // Migration 75's strip logic: remove the legacy attachments tag ONLY from events that are
2232    // provably backfilled (have a table row); un-backfilled events keep their tag as the fallback.
2233    #[tokio::test]
2234    async fn attachment_tag_strip_is_gated_on_backfill() {
2235        let (_tmp, _guard) = init_test_db();
2236        let a = Attachment { id: "h1".into(), extension: "png".into(), name: "f.png".into(), downloaded: false, ..Default::default() };
2237        let mk = |id: &str, secs: u64| Message {
2238            id: id.into(), content: String::new(), at: secs * 1000, mine: false,
2239            npub: Some("npub1s".into()), attachments: vec![a.clone()], ..Default::default()
2240        };
2241        save_message("npub1s", &mk("bf", 1000)).await.unwrap();
2242        save_message("npub1s", &mk("unbf", 2000)).await.unwrap();
2243
2244        {
2245            let conn = crate::db::get_write_connection_guard_static().unwrap();
2246            let inner = serde_json::to_string(&vec![a.clone()]).unwrap();
2247            let with_tag = |ms: &str| serde_json::to_string(&vec![
2248                vec!["ms".to_string(), ms.to_string()],
2249                vec!["attachments".to_string(), inner.clone()],
2250            ]).unwrap();
2251            // Both events carry a legacy tag; only `unbf` loses its table rows (un-backfilled).
2252            conn.execute("UPDATE events SET tags=?1 WHERE id='bf'", rusqlite::params![with_tag("5")]).unwrap();
2253            conn.execute("UPDATE events SET tags=?1 WHERE id='unbf'", rusqlite::params![with_tag("6")]).unwrap();
2254            conn.execute("DELETE FROM attachments WHERE event_id='unbf'", []).unwrap();
2255
2256            // Replicate migration 75's gated strip.
2257            let events: Vec<(String, String)> = {
2258                let mut stmt = conn.prepare("SELECT id, tags FROM events WHERE tags LIKE '%attachments%' AND id IN (SELECT DISTINCT event_id FROM attachments)").unwrap();
2259                let m = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))).unwrap();
2260                m.flatten().collect()
2261            };
2262            for (id, tj) in events {
2263                let mut tags: Vec<Vec<String>> = serde_json::from_str(&tj).unwrap();
2264                tags.retain(|t| t.first().map(|s| s.as_str()) != Some("attachments"));
2265                conn.execute("UPDATE events SET tags=?1 WHERE id=?2",
2266                    rusqlite::params![serde_json::to_string(&tags).unwrap(), id]).unwrap();
2267            }
2268
2269            let bf: String = conn.query_row("SELECT tags FROM events WHERE id='bf'", [], |r| r.get(0)).unwrap();
2270            assert!(!bf.contains("attachments"), "backfilled event's attachments tag stripped");
2271            assert!(bf.contains("\"ms\""), "sibling ms tag survives the strip");
2272            let unbf: String = conn.query_row("SELECT tags FROM events WHERE id='unbf'", [], |r| r.get(0)).unwrap();
2273            assert!(unbf.contains("attachments"), "un-backfilled event keeps its tag (no table row)");
2274        }
2275
2276        // The un-backfilled event still renders via the read fallback after the strip.
2277        let chat_int = crate::db::id_cache::get_or_create_chat_id("npub1s").unwrap();
2278        let views = get_message_views(chat_int, 10, 0).await.unwrap();
2279        assert_eq!(views.iter().find(|m| m.id == "unbf").unwrap().attachments.len(), 1, "fallback still renders unbf");
2280    }
2281
2282    // Mark-as-unread anchors from the FULL DB history (a community row often holds only a preview
2283    // message in RAM). The anchor lands strictly before the target's second so the count query's
2284    // strict `>` still counts the newest contact message. Covers the community repro + edge cases.
2285    #[tokio::test]
2286    async fn compute_unread_anchor_covers_the_cases() {
2287        let (_tmp, _guard) = init_test_db();
2288        let mk = |id: &str, secs: u64, mine: bool| Message {
2289            id: id.into(), content: "x".into(), at: secs * 1000, mine,
2290            npub: (!mine).then(|| "npub1sender".to_string()),
2291            ..Default::default()
2292        };
2293        let unread = |chat: &'static str| async move {
2294            unread_counts().await.unwrap().get(chat).copied().unwrap_or(0)
2295        };
2296        let set_lr = |chat: &str, lr: &str| {
2297            let conn = crate::db::get_write_connection_guard_static().unwrap();
2298            conn.execute("UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
2299                rusqlite::params![lr, chat]).unwrap();
2300        };
2301
2302        // (A) The community repro: we spoke long ago, they kept talking. Anchor = second-newest
2303        // contact message → exactly one unread, whatever the RAM cache held.
2304        let a = "npub1anchorA";
2305        save_message(a, &mk("a_mine", 1000, true)).await.unwrap();
2306        for i in 0..8u64 { save_message(a, &mk(&format!("a{i}"), 2000 + i, false)).await.unwrap(); }
2307        assert_eq!(compute_unread_anchor(a).await.unwrap(), UnreadMark::Anchor("a6".into()));
2308        set_lr(a, "a6");
2309        assert_eq!(unread(a).await, 1, "A: newest contact message is the sole unread");
2310
2311        // (B) We spoke last → NoOp (no phantom badge, no snap-back jiggle).
2312        let b = "npub1anchorB";
2313        save_message(b, &mk("b0", 2000, false)).await.unwrap();
2314        save_message(b, &mk("b_mine", 2001, true)).await.unwrap();
2315        assert_eq!(compute_unread_anchor(b).await.unwrap(), UnreadMark::NoOp);
2316
2317        // (C) Same-second tail: the two newest share a second. The anchor must skip to a strictly
2318        // earlier second, so both same-second messages surface instead of snapping back to read.
2319        let c = "npub1anchorC";
2320        save_message(c, &mk("c0", 3000, false)).await.unwrap();
2321        save_message(c, &mk("c1", 3005, false)).await.unwrap();
2322        save_message(c, &mk("c2", 3005, false)).await.unwrap();
2323        assert_eq!(compute_unread_anchor(c).await.unwrap(), UnreadMark::Anchor("c0".into()));
2324        set_lr(c, "c0");
2325        assert_eq!(unread(c).await, 2, "C: same-second tail both count");
2326
2327        // (D) The newest contact message is the chat's first → Clear (never-read) → it still counts.
2328        let d = "npub1anchorD";
2329        save_message(d, &mk("d0", 4000, false)).await.unwrap();
2330        assert_eq!(compute_unread_anchor(d).await.unwrap(), UnreadMark::Clear);
2331        set_lr(d, "");
2332        assert_eq!(unread(d).await, 1, "D: lone contact message surfaces");
2333
2334        // (E) No contact message at all (only our own) → NoOp.
2335        let e = "npub1anchorE";
2336        save_message(e, &mk("e_mine", 5000, true)).await.unwrap();
2337        assert_eq!(compute_unread_anchor(e).await.unwrap(), UnreadMark::NoOp);
2338    }
2339
2340    // Regression: a "read to here" marker that lands on a system event (kind 30078, not a counted
2341    // kind, e.g. the windowed jump-reveal path marking off the raw tail) must still clear unread.
2342    // The anchor keys off the marker row's time whatever its kind, so it can't wedge at 99+.
2343    /// A blank conversation id is refused, never minted into a chat row. Left to
2344    /// `get_or_create_chat_id` it created a chat keyed by "" — and because the
2345    /// identifier is UNIQUE, presence from EVERY channel-less community collapsed
2346    /// into that one unopenable row (82 events across weeks, on a live account).
2347    #[tokio::test]
2348    async fn a_system_event_with_a_blank_conversation_id_is_refused() {
2349        let (_tmp, _guard) = init_test_db();
2350        let before = chat_row_count();
2351        for blank in ["", "   "] {
2352            assert!(
2353                save_system_event_at("ev", blank, SystemEventType::MemberJoined, "npubX", None, 100, None, None)
2354                    .await
2355                    .is_err(),
2356                "a blank conversation id must be refused, not minted"
2357            );
2358        }
2359        assert_eq!(chat_row_count(), before, "and no chat row is created");
2360        // A real id still works.
2361        assert!(
2362            save_system_event_at("ev2", "real-chat", SystemEventType::MemberJoined, "npubX", None, 100, None, None)
2363                .await
2364                .unwrap()
2365        );
2366    }
2367
2368    fn chat_row_count() -> i64 {
2369        let conn = crate::db::get_db_connection_guard_static().unwrap();
2370        conn.query_row("SELECT count(*) FROM chats", [], |r| r.get(0)).unwrap()
2371    }
2372
2373    #[tokio::test]
2374    async fn unread_clears_when_last_read_is_a_system_event() {
2375        let (_tmp, _guard) = init_test_db();
2376        let chat = "npub1sysevtdm";
2377        let mk = |id: &str, secs: u64| Message {
2378            id: id.into(), content: "x".into(), at: secs * 1000, mine: false,
2379            npub: Some("npub1sender".to_string()), ..Default::default()
2380        };
2381        let unread = || async { unread_counts().await.unwrap().get(chat).copied().unwrap_or(0) };
2382
2383        // 5 contact messages, never read → all unread. No own message, so the ONLY viable anchor
2384        // is last_read (this is the case that used to stick at a permanent count).
2385        for i in 0..5u64 {
2386            save_message(chat, &mk(&format!("m{i}"), 1000 + i)).await.unwrap();
2387        }
2388        assert_eq!(unread().await, 5, "never-read backlog");
2389
2390        // A system event is the newest row (a join notification, after every contact message).
2391        save_system_event_at("sysev", chat, SystemEventType::MemberJoined, "npubX", None, 2000, None, None).await.unwrap();
2392
2393        // last_read pinned to that system event (kind 30078) — the reported bad state.
2394        {
2395            let conn = crate::db::get_write_connection_guard_static().unwrap();
2396            conn.execute(
2397                "UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
2398                rusqlite::params!["sysev", chat],
2399            ).unwrap();
2400        }
2401        assert_eq!(unread().await, 0, "read marker on a system event still clears the badge");
2402    }
2403
2404    // Deleting a message adjusts unread correctly: removing an UNREAD message drops the count by
2405    // one, removing the read MARKER retreats it to the prior message (never collapses to 99+), and
2406    // removing the last read message clears the marker without over-counting.
2407    #[tokio::test]
2408    async fn deleting_a_message_adjusts_unread_without_wedging() {
2409        let (_tmp, _guard) = init_test_db();
2410        let chat = "npub1delunread";
2411        let mk = |id: &str, secs: u64| Message {
2412            id: id.into(), content: "x".into(), at: secs * 1000, mine: false,
2413            npub: Some("npub1sender".to_string()), ..Default::default()
2414        };
2415        let unread = || async { unread_counts().await.unwrap().get(chat).copied().unwrap_or(0) };
2416        let set_marker = |id: &str| {
2417            let conn = crate::db::get_write_connection_guard_static().unwrap();
2418            conn.execute("UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
2419                rusqlite::params![id, chat]).unwrap();
2420        };
2421        let marker = || -> String {
2422            let conn = crate::db::get_db_connection_guard_static().unwrap();
2423            conn.query_row("SELECT last_read FROM chats WHERE chat_identifier = ?1",
2424                rusqlite::params![chat], |r| r.get::<_, String>(0)).unwrap()
2425        };
2426
2427        // m0..m5, read up to m1 → m2,m3,m4,m5 unread.
2428        for i in 0..6u64 { save_message(chat, &mk(&format!("m{i}"), 1000 + i)).await.unwrap(); }
2429        set_marker("m1");
2430        assert_eq!(unread().await, 4, "m2..m5 unread");
2431
2432        // Delete an UNREAD mid-block message → badge drops by one, marker untouched.
2433        delete_event("m3").await.unwrap();
2434        assert_eq!(unread().await, 3, "one unread deleted → badge minus one");
2435        assert_eq!(marker(), "m1", "deleting an unread message leaves the marker alone");
2436
2437        // Delete the read MARKER → retreats to the prior surviving message (m0), unread unchanged.
2438        delete_event("m1").await.unwrap();
2439        assert_eq!(marker(), "m0", "marker retreats to the newest survivor before it");
2440        assert_eq!(unread().await, 3, "retreat keeps the count, no collapse to 99+");
2441
2442        // Delete the last surviving read message → marker clears, still exactly the unread block.
2443        delete_event("m0").await.unwrap();
2444        assert_eq!(marker(), "", "no earlier survivor → marker clears");
2445        assert_eq!(unread().await, 3, "cleared marker counts only the true unread survivors");
2446    }
2447
2448    // Edits are event-sourced for BOTH transports: a MESSAGE_EDIT event folds into the target's
2449    // history on reload (latest content + revisions + the edit's own emoji). Community used to
2450    // overwrite the row and lose all of this — this locks in the unified fold.
2451    #[tokio::test]
2452    async fn edit_event_folds_into_history_on_reload() {
2453        let (_tmp, _guard) = init_test_db();
2454        let chat = "channel_edit_fold";
2455        save_message(chat, &Message {
2456            id: "orig1".into(), content: "original".into(), at: 5_000_000,
2457            npub: Some("npub1author".into()), ..Default::default()
2458        }).await.unwrap();
2459
2460        let cid = crate::db::id_cache::get_chat_id_by_identifier(chat).unwrap();
2461        let emoji = vec![crate::types::EmojiTag { shortcode: "wave".into(), url: "u/wave".into() }];
2462        save_edit_event("edit1", "orig1", "edited :wave:", &emoji, cid, None, "npub1author").await.unwrap();
2463
2464        let m = get_message_views(cid, 50, 0).await.unwrap()
2465            .into_iter().find(|m| m.id == "orig1").expect("message reloaded");
2466        assert!(m.edited, "folded edit sets the edited flag");
2467        let h = m.edit_history.as_ref().expect("history reconstructed from the edit event");
2468        assert_eq!(h.len(), 2, "original + one edit");
2469        assert_eq!(h[0].content, "original");
2470        assert_eq!(h[1].content, "edited :wave:");
2471        assert_eq!(m.content, "edited :wave:", "latest revision is the displayed content");
2472        assert_eq!(m.emoji_tags.len(), 1, "the edit's own emoji folds onto the message");
2473        assert_eq!(m.emoji_tags[0].shortcode, "wave");
2474    }
2475
2476    // The bulk-sync persist path: one transaction must land events + attachments + reactions
2477    // with the same shape save_message produces, and slice order must set rowid order (the
2478    // same-timestamp pagination tiebreak).
2479    #[tokio::test]
2480    async fn batched_save_matches_single_save_shape() {
2481        let (_tmp, _guard) = init_test_db();
2482        let chat = "channel_batch1";
2483        let att = crate::types::Attachment {
2484            id: "atthash1".into(), extension: "png".into(), name: "a.png".into(),
2485            url: "https://x/att".into(), downloaded: false, ..Default::default()
2486        };
2487        let reaction = Reaction {
2488            id: "react_b1".into(), reference_id: "b1".into(),
2489            author_id: "npub1reactor".into(), emoji: "👍".into(), emoji_url: None,
2490        };
2491        // Same `at` second across the batch — rowid is the only orderer.
2492        let msgs: Vec<Message> = (0..5u64).map(|i| Message {
2493            id: format!("b{i}"), content: format!("c{i}"), at: 7_000_000,
2494            npub: Some("npub1sender".into()),
2495            attachments: if i == 2 { vec![att.clone()] } else { Vec::new() },
2496            reactions: if i == 1 { vec![reaction.clone()] } else { Vec::new() },
2497            ..Default::default()
2498        }).collect();
2499        let refs: Vec<&Message> = msgs.iter().collect();
2500
2501        let saved = save_messages_batch(chat, &refs).await.unwrap();
2502        assert_eq!(saved, 5, "every message written");
2503
2504        for i in 0..5u64 {
2505            assert!(event_exists(&format!("b{i}")).unwrap(), "b{i} row exists");
2506        }
2507        assert!(event_exists("react_b1").unwrap(), "reaction landed as its own kind-7 row");
2508        let atts = crate::db::attachments::get_attachments_for_event("b2").unwrap();
2509        assert_eq!(atts.len(), 1, "attachment row committed with its event");
2510        assert_eq!(atts[0].id, "atthash1");
2511
2512        // rowid order == slice order despite identical timestamps.
2513        let conn = crate::db::get_db_connection_guard_static().unwrap();
2514        let ids: Vec<String> = conn
2515            .prepare("SELECT id FROM events WHERE id IN ('b0','b1','b2','b3','b4') ORDER BY rowid")
2516            .unwrap()
2517            .query_map([], |r| r.get(0)).unwrap()
2518            .flatten().collect();
2519        assert_eq!(ids, vec!["b0", "b1", "b2", "b3", "b4"], "insert order preserves the rowid tiebreak");
2520    }
2521
2522    // A batched re-save must keep save_message's upsert semantics: wrapper_event_id is
2523    // COALESCE-preserved, and a duplicate reaction in the batch never doubles its row.
2524    #[tokio::test]
2525    async fn batched_resave_preserves_wrapper_and_dedups_reactions() {
2526        let (_tmp, _guard) = init_test_db();
2527        let chat = "channel_batch2";
2528        let reaction = Reaction {
2529            id: "react_rs".into(), reference_id: "rs1".into(),
2530            author_id: "npub1reactor".into(), emoji: "🔥".into(), emoji_url: None,
2531        };
2532        let mut msg = Message {
2533            id: "rs1".into(), content: "hello".into(), at: 8_000_000,
2534            npub: Some("npub1sender".into()),
2535            wrapper_event_id: Some("wrap_original".into()),
2536            reactions: vec![reaction],
2537            ..Default::default()
2538        };
2539        save_message(chat, &msg).await.unwrap();
2540
2541        // Re-delivery re-save without a wrapper id, reaction still attached.
2542        msg.wrapper_event_id = None;
2543        let saved = save_messages_batch(chat, &[&msg]).await.unwrap();
2544        assert_eq!(saved, 1);
2545
2546        let conn = crate::db::get_db_connection_guard_static().unwrap();
2547        let wrapper: Option<String> = conn.query_row(
2548            "SELECT wrapper_event_id FROM events WHERE id = 'rs1'", [], |r| r.get(0),
2549        ).unwrap();
2550        assert_eq!(wrapper.as_deref(), Some("wrap_original"), "COALESCE keeps the known wrapper");
2551        let reaction_rows: i64 = conn.query_row(
2552            "SELECT COUNT(*) FROM events WHERE id = 'react_rs'", [], |r| r.get(0),
2553        ).unwrap();
2554        assert_eq!(reaction_rows, 1, "reaction row not duplicated by the re-save");
2555    }
2556
2557    // The DM stream's multi-chat flush: one call, one transaction, rows land under their own
2558    // chats with their gift-wrap ledger entries; a flush against a stale session drops the
2559    // buffer AND leaves the wrappers unledgered (that's what makes the drop recoverable).
2560    #[tokio::test]
2561    async fn batching_persist_flushes_multi_chat_and_drains_into_the_account_that_filled_it() {
2562        let (_tmp, _guard) = init_test_db();
2563        let handler = crate::event_handler::NoOpEventHandler;
2564        let batcher = crate::event_handler::BatchingPersist::new(&handler);
2565
2566        let mk = |id: &str, npub: &str| Message {
2567            id: id.into(), content: "x".into(), at: 9_000_000,
2568            npub: Some(npub.into()), ..Default::default()
2569        };
2570        // Mirror the commit path: buffering only ever happens AFTER the STATE add (the
2571        // flush drops anything not STATE-resident as deletion protection).
2572        let seed = |chat: &str, m: &Message| {
2573            let m = m.clone();
2574            let chat = chat.to_string();
2575            async move {
2576                let mut st = crate::state::STATE.lock().await;
2577                st.add_message_to_participant(&chat, &m);
2578            }
2579        };
2580        let wrap_a1 = ([0xA1u8; 32], 111u64);
2581        let wrap_b1 = ([0xB1u8; 32], 222u64);
2582        let a1 = mk("bp_a1", "npub1chata");
2583        let b1 = mk("bp_b1", "npub1chatb");
2584        let a2 = mk("bp_a2", "npub1chata");
2585        seed("npub1chata", &a1).await;
2586        seed("npub1chatb", &b1).await;
2587        seed("npub1chata", &a2).await;
2588
2589        use crate::event_handler::InboundEventHandler;
2590        assert!(batcher.buffer_persist("npub1chata", &a1, Some(wrap_a1)), "batcher owns the persist");
2591        assert!(batcher.buffer_persist("npub1chatb", &b1, Some(wrap_b1)));
2592        assert!(batcher.buffer_persist("npub1chata", &a2, None));
2593        assert_eq!(batcher.buffered(), 3);
2594
2595        let ledgered = |bytes: [u8; 32]| {
2596            let id = nostr_sdk::prelude::EventId::from_byte_array(bytes);
2597            crate::db::wrappers::load_negentropy_items().unwrap().iter().any(|(e, _)| *e == id)
2598        };
2599        assert!(!ledgered(wrap_a1.0), "wrapper unledgered while its message sits buffered");
2600
2601        assert_eq!(batcher.flush().await, 3, "all buffered messages written");
2602        assert_eq!(batcher.buffered(), 0);
2603        assert!(event_exists("bp_a1").unwrap() && event_exists("bp_b1").unwrap() && event_exists("bp_a2").unwrap());
2604        assert!(ledgered(wrap_a1.0) && ledgered(wrap_b1.0), "wrappers ledgered with the flush");
2605        let a = crate::db::id_cache::get_chat_id_by_identifier("npub1chata").unwrap();
2606        let b = crate::db::id_cache::get_chat_id_by_identifier("npub1chatb").unwrap();
2607        assert_ne!(a, b, "rows grouped under their own chats");
2608
2609        // A swap between buffering and flushing. The batcher holds the account
2610        // that filled it, so the drain lands there rather than in whoever is
2611        // live by the time it runs — the buffer is one account's inbox.
2612        let filled_under = crate::db::current_session();
2613        let late = mk("bp_late", "npub1chata");
2614        seed("npub1chata", &late).await;
2615        let wrap_late = ([0x5Eu8; 32], 333u64);
2616        batcher.buffer_persist("npub1chata", &late, Some(wrap_late));
2617
2618        let next = make_test_npub(TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
2619        std::fs::create_dir_all(crate::db::shared_test_data_dir().join(&next)).unwrap();
2620        crate::db::set_current_account(next.clone()).unwrap();
2621        crate::db::init_database(&next).unwrap();
2622
2623        assert_eq!(batcher.flush().await, 1, "the buffer still drains");
2624        assert!(
2625            crate::db::with_session(filled_under, async {
2626                event_exists("bp_late").unwrap() && ledgered(wrap_late.0)
2627            })
2628            .await,
2629            "written to the account that received it, and its wrapper ledgered there"
2630        );
2631        assert!(!event_exists("bp_late").unwrap(), "the account swapped in never sees it");
2632        assert_eq!(batcher.buffered(), 0, "and the buffer is empty either way");
2633    }
2634
2635    // A deletion landing while its target sits buffered must not resurrect the message:
2636    // the same-task path purges via on_message_deleted, and the cross-task path (live
2637    // subscription, different handler) is caught by the flush's deletion-tombstone filter.
2638    // The filter keys on the POSITIVE tombstone, never STATE absence — an LRU-evicted (but
2639    // not deleted) message MUST still persist and ledger, or archive sync could silently
2640    // drop the exact history it exists to persist.
2641    #[tokio::test]
2642    async fn a_delete_tombstone_survives_a_restart() {
2643        // NIP-09 is best-effort: a relay that ignored it re-serves the wrap days
2644        // later. The events row is gone by then and the in-session tombstone set
2645        // died with the process — only the durable row lets ingest keep refusing.
2646        let (_tmp, _guard) = init_test_db();
2647        let msg = Message {
2648            id: "resurrect-me".into(), content: "note".into(), at: 1_000_000,
2649            mine: true, ..Default::default()
2650        };
2651        save_message("npub1notes", &msg).await.unwrap();
2652        add_message_tombstone("resurrect-me").unwrap();
2653        delete_event("resurrect-me").await.unwrap();
2654
2655        // Simulated restart/swap: the in-session set dies...
2656        crate::db::close_database();
2657        assert!(!crate::state::was_message_deleted("resurrect-me"), "session set cleared");
2658        // ...and account init re-seeds the refusal from the durable rows.
2659        crate::state::seed_message_tombstones(load_message_tombstones().unwrap());
2660        assert!(crate::state::was_message_deleted("resurrect-me"), "the durable tombstone re-seeds");
2661    }
2662
2663    #[tokio::test]
2664    async fn buffered_message_deleted_before_flush_never_persists() {
2665        let (_tmp, _guard) = init_test_db();
2666        let handler = crate::event_handler::NoOpEventHandler;
2667        let batcher = crate::event_handler::BatchingPersist::new(&handler);
2668        use crate::event_handler::InboundEventHandler;
2669
2670        let chat = "npub1delchat";
2671        let mk = |id: &str| Message {
2672            id: id.into(), content: "x".into(), at: 9_500_000,
2673            npub: Some(chat.into()), ..Default::default()
2674        };
2675        let ledgered = |bytes: [u8; 32]| {
2676            let id = nostr_sdk::prelude::EventId::from_byte_array(bytes);
2677            crate::db::wrappers::load_negentropy_items().unwrap().iter().any(|(e, _)| *e == id)
2678        };
2679
2680        // Same-task deletion (sync stream): commit_deletion fires on_message_deleted on
2681        // the batching handler → the buffered entry purges immediately.
2682        let m1 = mk("del_sametask");
2683        {
2684            let mut st = crate::state::STATE.lock().await;
2685            st.add_message_to_participant(chat, &m1);
2686        }
2687        batcher.buffer_persist(chat, &m1, Some(([0xD1u8; 32], 444)));
2688        {
2689            let mut st = crate::state::STATE.lock().await;
2690            st.remove_message("del_sametask");
2691        }
2692        batcher.on_message_deleted(chat, "del_sametask");
2693        assert_eq!(batcher.buffered(), 0, "deletion purges the buffered target");
2694
2695        // Cross-task deletion (live subscription, plain handler): no purge call — the
2696        // deletion tombstone (recorded by commit_deletion) drops it at flush time.
2697        let m2 = mk("del_crosstask");
2698        {
2699            let mut st = crate::state::STATE.lock().await;
2700            st.add_message_to_participant(chat, &m2);
2701        }
2702        batcher.buffer_persist(chat, &m2, Some(([0xD2u8; 32], 555)));
2703        crate::state::note_message_deleted("del_crosstask");
2704
2705        // LRU eviction is NOT deletion: gone from STATE, no tombstone → must persist.
2706        let m3 = mk("evicted_ok");
2707        let wrap_evicted = ([0xE0u8; 32], 666u64);
2708        {
2709            let mut st = crate::state::STATE.lock().await;
2710            st.add_message_to_participant(chat, &m3);
2711            st.remove_message("evicted_ok");
2712        }
2713        batcher.buffer_persist(chat, &m3, Some(wrap_evicted));
2714
2715        assert_eq!(batcher.flush().await, 1, "tombstoned target dropped, evicted message written");
2716        assert!(!event_exists("del_sametask").unwrap(), "purged message never persisted");
2717        assert!(!event_exists("del_crosstask").unwrap(), "tombstoned message never persisted");
2718        assert!(event_exists("evicted_ok").unwrap(), "evicted-but-not-deleted message persisted");
2719        assert!(ledgered(wrap_evicted.0), "evicted message's wrapper ledgered with it");
2720    }
2721}
2722
2723/// The stored context a pin proof needs to recover a message's wrap: its
2724/// `wrapper_event_id` and the rumor's stored tags (for the epoch binding).
2725pub fn get_event_wrap_context(event_id: &str) -> Result<Option<(Option<String>, Vec<Vec<String>>)>, String> {
2726    let conn = super::get_db_connection_guard_static()?;
2727    let row: Option<(Option<String>, String)> = conn
2728        .query_row(
2729            "SELECT wrapper_event_id, tags FROM events WHERE id = ?1",
2730            rusqlite::params![event_id],
2731            |r| Ok((r.get(0)?, r.get(1)?)),
2732        )
2733        .optional()
2734        .map_err(|e| format!("get wrap context: {e}"))?;
2735    Ok(row.map(|(wrap, tags_json)| {
2736        let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
2737        (wrap, tags)
2738    }))
2739}