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.
753///
754/// Join/leave lines carry their member in `npub`, so a banned member's arrival is
755/// dropped by the same rule that hides what they said. These have their OWN query —
756/// the chat page never sees them — so filtering the message page alone left a wall of
757/// "has joined" from accounts the client otherwise refuses to render.
758pub fn get_system_events_for_chat(conversation_id: &str) -> Result<Vec<StoredEvent>, String> {
759    let conn = super::get_db_connection_guard_static()?;
760    let chat_id: i64 = conn.query_row(
761        "SELECT id FROM chats WHERE chat_identifier = ?1",
762        rusqlite::params![conversation_id], |row| row.get(0)
763    ).map_err(|_| "Chat not found")?;
764
765    let mut stmt = conn.prepare(
766        &format!("SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
767         created_at, received_at, mine, pending, failed, wrapper_event_id, npub \
768         FROM events WHERE chat_id = ?1 AND kind = ?2{} ORDER BY created_at ASC, received_at ASC",
769         ban_filter_sql(3))
770    ).map_err(|e| format!("Failed to prepare: {}", e))?;
771
772    let rows = stmt.query_map(
773        rusqlite::params![chat_id, event_kind::APPLICATION_SPECIFIC as i32, community_of_chat(&conn, chat_id)],
774        |row| {
775            let tags_json: String = row.get(5)?;
776            let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
777            Ok(StoredEvent {
778                id: row.get(0)?, kind: row.get::<_, i32>(1)? as u16,
779                chat_id: row.get(2)?, user_id: row.get(3)?, content: row.get(4)?,
780                tags, reference_id: row.get(6)?,
781                created_at: row.get::<_, i64>(7)? as u64, received_at: row.get::<_, i64>(8)? as u64,
782                mine: row.get::<_, i32>(9)? != 0, pending: row.get::<_, i32>(10)? != 0,
783                failed: row.get::<_, i32>(11)? != 0, wrapper_event_id: row.get(12)?,
784                npub: row.get(13)?, preview_metadata: None,
785            })
786        }
787    ).map_err(|e| format!("Failed to query: {}", e))?;
788
789    let mut events = Vec::new();
790    for row in rows {
791        let event = row.map_err(|e| format!("Failed to read event: {}", e))?;
792        if event.tags.iter().any(|t| t.len() >= 2 && t[0] == "d" && t[1] == "system-event") {
793            events.push(event);
794        }
795    }
796    Ok(events)
797}
798
799// ============================================================================
800// Event Read Operations
801// ============================================================================
802
803/// Helper to parse a SQLite row into a StoredEvent.
804fn parse_event_row(row: &rusqlite::Row) -> rusqlite::Result<StoredEvent> {
805    let tags_json: String = row.get(5)?;
806    let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
807
808    Ok(StoredEvent {
809        id: row.get(0)?,
810        kind: row.get::<_, i32>(1)? as u16,
811        chat_id: row.get(2)?,
812        user_id: row.get(3)?,
813        content: row.get(4)?,
814        tags,
815        reference_id: row.get(6)?,
816        created_at: row.get::<_, i64>(7)? as u64,
817        received_at: row.get::<_, i64>(8)? as u64,
818        mine: row.get::<_, i32>(9)? != 0,
819        pending: row.get::<_, i32>(10)? != 0,
820        failed: row.get::<_, i32>(11)? != 0,
821        wrapper_event_id: row.get(12)?,
822        npub: row.get(13)?,
823        preview_metadata: row.get(14)?,
824    })
825}
826
827/// The community this chat's channel belongs to, or `None` for a DM.
828///
829/// Resolved once per query rather than joined per row: the ban predicate then
830/// seeks `community_bans`' primary key instead of walking chats -> channels for
831/// every candidate event.
832fn community_of_chat(conn: &rusqlite::Connection, chat_id: i64) -> Option<String> {
833    conn.query_row(
834        "SELECT cc.community_id FROM community_channels cc \
835         JOIN chats c ON c.chat_identifier = cc.channel_id WHERE c.id = ?1",
836        rusqlite::params![chat_id],
837        |r| r.get::<_, String>(0),
838    )
839    .ok()
840}
841
842/// Excludes a banned author's events BEFORE `LIMIT`.
843///
844/// A ban has to hide what they already posted, not just stop the next message —
845/// and it has to do it here, in SQL. Filtering the page after it is fetched
846/// returns fewer rows than asked for, which reads to the caller as "end of
847/// history": backscroll stops early, and a wall of banned events hides
848/// everything older than itself. `?{n}` is NULL for a DM, where the whole
849/// predicate short-circuits.
850fn ban_filter_sql(param: usize) -> String {
851    format!(
852        " AND (?{param} IS NULL OR events.npub IS NULL OR NOT EXISTS ( \
853            SELECT 1 FROM community_bans b \
854            WHERE b.community_id = ?{param} AND b.npub = events.npub))"
855    )
856}
857
858/// Get events for a chat with pagination, optionally filtered by kind.
859/// Message/edit content is decrypted via maybe_decrypt.
860pub async fn get_events(
861    chat_id: i64,
862    kinds: Option<&[u16]>,
863    limit: usize,
864    offset: usize,
865) -> Result<Vec<StoredEvent>, String> {
866    let events: Vec<StoredEvent> = {
867        let conn = super::get_db_connection_guard_static()?;
868        let community = community_of_chat(&conn, chat_id);
869
870        if let Some(k) = kinds {
871            let kind_placeholders: String = (0..k.len())
872                .map(|i| format!("?{}", i + 2))
873                .collect::<Vec<_>>()
874                .join(",");
875            let limit_param = k.len() + 2;
876            let offset_param = k.len() + 3;
877            let community_param = k.len() + 4;
878
879            let sql = format!(
880                "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
881                 created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata \
882                 FROM events WHERE chat_id = ?1 AND kind IN ({}){} \
883                 ORDER BY created_at DESC, received_at DESC \
884                 LIMIT ?{} OFFSET ?{}",
885                kind_placeholders, ban_filter_sql(community_param), limit_param, offset_param
886            );
887
888            let mut stmt = conn.prepare(&sql)
889                .map_err(|e| format!("Failed to prepare events query: {}", e))?;
890
891            match k.len() {
892                1 => {
893                    let rows = stmt.query_map(
894                        rusqlite::params![chat_id, k[0] as i32, limit as i64, offset as i64, community],
895                        parse_event_row
896                    ).map_err(|e| format!("Failed to query events: {}", e))?;
897                    rows.filter_map(|r| r.ok()).collect()
898                },
899                2 => {
900                    let rows = stmt.query_map(
901                        rusqlite::params![chat_id, k[0] as i32, k[1] as i32, limit as i64, offset as i64, community],
902                        parse_event_row
903                    ).map_err(|e| format!("Failed to query events: {}", e))?;
904                    rows.filter_map(|r| r.ok()).collect()
905                },
906                3 => {
907                    let rows = stmt.query_map(
908                        rusqlite::params![chat_id, k[0] as i32, k[1] as i32, k[2] as i32, limit as i64, offset as i64, community],
909                        parse_event_row
910                    ).map_err(|e| format!("Failed to query events: {}", e))?;
911                    rows.filter_map(|r| r.ok()).collect()
912                },
913                _ => return Err("Unsupported number of kinds".to_string()),
914            }
915        } else {
916            let mut stmt = conn.prepare(
917                &format!("SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
918                 created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata \
919                 FROM events WHERE chat_id = ?1{} \
920                 ORDER BY created_at DESC, received_at DESC \
921                 LIMIT ?2 OFFSET ?3", ban_filter_sql(4))
922            ).map_err(|e| format!("Failed to prepare events query: {}", e))?;
923
924            let rows = stmt.query_map(
925                rusqlite::params![chat_id, limit as i64, offset as i64, community],
926                parse_event_row
927            ).map_err(|e| format!("Failed to query events: {}", e))?;
928            rows.filter_map(|r| r.ok()).collect()
929        }
930    };
931
932    // Decrypt message content
933    let mut decrypted = Vec::with_capacity(events.len());
934    for mut event in events {
935        if event.kind == event_kind::CHAT_MESSAGE || event.kind == event_kind::PRIVATE_DIRECT_MESSAGE {
936            event.content = crate::crypto::maybe_decrypt(event.content).await
937                .unwrap_or_else(|_| "[Decryption failed]".to_string());
938        }
939        decrypted.push(event);
940    }
941
942    Ok(decrypted)
943}
944
945/// Get events that reference specific message IDs (reactions, edits).
946pub async fn get_related_events(
947    reference_ids: &[String],
948) -> Result<Vec<StoredEvent>, String> {
949    if reference_ids.is_empty() {
950        return Ok(Vec::new());
951    }
952
953    let conn = super::get_db_connection_guard_static()?;
954
955    let placeholders: String = reference_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
956    let sql = format!(
957        "SELECT id, kind, chat_id, user_id, content, tags, reference_id, \
958         created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata \
959         FROM events WHERE reference_id IN ({}) \
960         ORDER BY created_at ASC, received_at ASC",
961        placeholders
962    );
963
964    let mut stmt = conn.prepare(&sql)
965        .map_err(|e| format!("Failed to prepare related events query: {}", e))?;
966
967    let params: Vec<&dyn rusqlite::ToSql> = reference_ids.iter()
968        .map(|s| s as &dyn rusqlite::ToSql)
969        .collect();
970
971    let events: Vec<StoredEvent> = stmt.query_map(params.as_slice(), parse_event_row)
972        .map_err(|e| format!("Failed to query related events: {}", e))?
973        .filter_map(|r| r.ok())
974        .collect();
975
976    Ok(events)
977}
978
979/// Context data for a replied-to message.
980pub struct ReplyContext {
981    pub content: String,
982    pub npub: Option<String>,
983    pub has_attachment: bool,
984    /// Extension of the attachment, when the replied-to message is a file, so
985    /// the reply quote can label the type even when the target is off-screen.
986    pub extension: Option<String>,
987    /// The target's NIP-30 emoji tags — without them an off-screen quote
988    /// renders raw `:shortcodes:` and nothing ever corrects it.
989    pub emoji_tags: Vec<crate::types::EmojiTag>,
990}
991
992/// Fetch reply context for a list of message IDs.
993pub async fn get_reply_contexts(
994    message_ids: &[String],
995) -> Result<std::collections::HashMap<String, ReplyContext>, String> {
996    use std::collections::HashMap;
997
998    if message_ids.is_empty() {
999        return Ok(HashMap::new());
1000    }
1001
1002    let (events, edits): (Vec<(String, i32, String, Option<String>, Option<String>)>, Vec<(String, String, Option<String>)>) = {
1003        let conn = super::get_db_connection_guard_static()?;
1004
1005        let placeholders: String = (0..message_ids.len())
1006            .map(|i| format!("?{}", i + 1))
1007            .collect::<Vec<_>>()
1008            .join(",");
1009
1010        // Query original messages (tags carry the file-type/name for attachment quotes)
1011        let sql = format!(
1012            "SELECT id, kind, content, npub, tags FROM events WHERE id IN ({})",
1013            placeholders
1014        );
1015        let mut stmt = conn.prepare(&sql)
1016            .map_err(|e| format!("Failed to prepare reply context query: {}", e))?;
1017
1018        let params: Vec<&str> = message_ids.iter().map(|s| s.as_str()).collect();
1019        let params_dyn: Vec<&dyn rusqlite::ToSql> = params.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
1020
1021        let rows = stmt.query_map(params_dyn.as_slice(), |row| {
1022            Ok((row.get::<_, String>(0)?, row.get::<_, i32>(1)?,
1023                row.get::<_, String>(2)?, row.get::<_, Option<String>>(3)?,
1024                row.get::<_, Option<String>>(4)?))
1025        }).map_err(|e| format!("Failed to query reply contexts: {}", e))?;
1026        let events_result: Vec<_> = rows.filter_map(|r| r.ok()).collect();
1027        drop(stmt);
1028
1029        // Query latest edits
1030        let edit_sql = format!(
1031            "SELECT reference_id, content, tags FROM events \
1032             WHERE kind = {} AND reference_id IN ({}) \
1033             ORDER BY created_at DESC, received_at DESC",
1034            event_kind::MESSAGE_EDIT, placeholders
1035        );
1036        let mut edit_stmt = conn.prepare(&edit_sql)
1037            .map_err(|e| format!("Failed to prepare edit query: {}", e))?;
1038        let edit_rows = edit_stmt.query_map(params_dyn.as_slice(), |row| {
1039            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?,
1040                row.get::<_, Option<String>>(2)?))
1041        }).map_err(|e| format!("Failed to query edits: {}", e))?;
1042        let edits_result: Vec<_> = edit_rows.filter_map(|r| r.ok()).collect();
1043
1044        (events_result, edits_result)
1045    };
1046
1047    // Build latest edit map (first = most recent since ordered DESC)
1048    let mut latest_edits: HashMap<String, (String, Option<String>)> = HashMap::new();
1049    for (ref_id, content, tags) in edits {
1050        latest_edits.entry(ref_id).or_insert((content, tags));
1051    }
1052
1053    // Batch the file replies' attachments (one query, not one per reply) for the extension below.
1054    let file_ids: Vec<String> = events.iter()
1055        .filter(|(_, kind, _, _, _)| *kind == event_kind::FILE_ATTACHMENT as i32)
1056        .map(|(id, _, _, _, _)| id.clone())
1057        .collect();
1058    let atts_by_event = super::attachments::get_attachments_for_events(&file_ids).unwrap_or_default();
1059
1060    // Decrypt and build contexts
1061    let mut contexts = HashMap::new();
1062    for (id, kind, original_content, npub, tags) in events {
1063        let has_attachment = kind == event_kind::FILE_ATTACHMENT as i32;
1064        let latest_edit = latest_edits.get(&id);
1065        let content_to_decrypt = latest_edit
1066            .map(|(c, _)| c.clone())
1067            .unwrap_or(original_content);
1068
1069        // Same rule as the message loader: an edit's tags replace the
1070        // original's, so the quote's emoji match the content it shows.
1071        let parse_emoji = |json: &Option<String>| -> Vec<crate::types::EmojiTag> {
1072            json.as_deref()
1073                .and_then(|t| serde_json::from_str::<Vec<Vec<String>>>(t).ok())
1074                .map(|parsed| crate::types::EmojiTag::extract_from_stored(&parsed))
1075                .unwrap_or_default()
1076        };
1077        let emoji_tags = match latest_edit {
1078            Some((_, edit_tags)) => parse_emoji(edit_tags),
1079            None => parse_emoji(&tags),
1080        };
1081
1082        let decrypted_content = if kind == event_kind::CHAT_MESSAGE as i32
1083            || kind == event_kind::PRIVATE_DIRECT_MESSAGE as i32
1084        {
1085            crate::crypto::maybe_decrypt(content_to_decrypt).await
1086                .unwrap_or_else(|_| "[Decryption failed]".to_string())
1087        } else {
1088            String::new()
1089        };
1090
1091        // The first attachment's extension lets the quote show the file type. From the table, with a
1092        // legacy-tag fallback for an un-backfilled pre-migration row.
1093        let extension = if has_attachment {
1094            atts_by_event.get(&id)
1095                .and_then(|atts| atts.first())
1096                .map(|a| a.extension.to_lowercase())
1097                .filter(|e| !e.is_empty())
1098                .or_else(|| tags.as_deref()
1099                    .and_then(|t| serde_json::from_str::<Vec<Vec<String>>>(t).ok())
1100                    .and_then(|parsed| parsed.into_iter()
1101                        .find(|t| t.first().map(|k| k == "attachments").unwrap_or(false))
1102                        .and_then(|t| t.into_iter().nth(1)))
1103                    .and_then(|json| serde_json::from_str::<Vec<serde_json::Value>>(&json).ok())
1104                    .and_then(|atts| atts.into_iter().next())
1105                    .and_then(|a| a.get("extension").and_then(|e| e.as_str()).map(str::to_lowercase))
1106                    .filter(|e| !e.is_empty()))
1107        } else {
1108            None
1109        };
1110
1111        contexts.insert(id, ReplyContext { content: decrypted_content, npub, has_attachment, extension, emoji_tags });
1112    }
1113
1114    Ok(contexts)
1115}
1116
1117/// Populate reply context for a PAGE of messages in one query — the sync/back-page
1118/// counterpart to [`populate_reply_context`]. Every path that emits a message straight to
1119/// the UI must resolve the quote first: the frontend renders the emitted payload, and a
1120/// reply whose parent lies outside the rendered window has no other source for it (a
1121/// parent inside the window is resolved from memory instead). Messages whose parent isn't
1122/// persisted yet are left untouched.
1123pub async fn populate_reply_contexts(messages: Vec<&mut Message>) -> Result<(), String> {
1124    let ids: Vec<String> = messages
1125        .iter()
1126        .filter(|m| !m.replied_to.is_empty())
1127        .map(|m| m.replied_to.clone())
1128        .collect();
1129    if ids.is_empty() {
1130        return Ok(());
1131    }
1132    let contexts = get_reply_contexts(&ids).await?;
1133    for message in messages {
1134        if let Some(ctx) = contexts.get(&message.replied_to) {
1135            message.replied_to_content = Some(ctx.content.clone());
1136            message.replied_to_npub = ctx.npub.clone();
1137            message.replied_to_has_attachment = Some(ctx.has_attachment);
1138            message.replied_to_attachment_extension = ctx.extension.clone();
1139            message.replied_to_emoji_tags = if ctx.emoji_tags.is_empty() { None } else { Some(ctx.emoji_tags.clone()) };
1140        }
1141    }
1142    Ok(())
1143}
1144
1145/// Populate reply context for a single message.
1146/// Used for real-time messages that don't go through get_message_views.
1147pub async fn populate_reply_context(message: &mut Message) -> Result<(), String> {
1148    if message.replied_to.is_empty() {
1149        return Ok(());
1150    }
1151
1152    let contexts = get_reply_contexts(&[message.replied_to.clone()]).await?;
1153
1154    if let Some(ctx) = contexts.get(&message.replied_to) {
1155        message.replied_to_content = Some(ctx.content.clone());
1156        message.replied_to_npub = ctx.npub.clone();
1157        message.replied_to_has_attachment = Some(ctx.has_attachment);
1158        message.replied_to_attachment_extension = ctx.extension.clone();
1159        message.replied_to_emoji_tags = if ctx.emoji_tags.is_empty() { None } else { Some(ctx.emoji_tags.clone()) };
1160    }
1161
1162    Ok(())
1163}
1164
1165/// Whether `event_id` is one of our own messages (`mine = 1`). A reply to our
1166/// own message is an implicit ping, so notifications treat it like a direct
1167/// @mention (breaks through a muted channel). Missing row → not ours → false.
1168pub fn is_own_event(event_id: &str) -> bool {
1169    let Ok(conn) = super::get_db_connection_guard_static() else {
1170        return false;
1171    };
1172    conn.query_row(
1173        "SELECT mine FROM events WHERE id = ?1",
1174        [event_id],
1175        |row| row.get::<_, i64>(0),
1176    )
1177    .map(|mine| mine == 1)
1178    .unwrap_or(false)
1179}
1180
1181// ============================================================================
1182// Message Views — compose full Messages from events + reactions + edits
1183// ============================================================================
1184
1185/// Extract a single tag value from raw tags JSON without full allocation.
1186fn extract_tag_from_json(tags_json: &str, key: &str) -> Option<String> {
1187    if tags_json.len() <= 2 { return None; }
1188    let pattern = format!("[\"{}\"", key);
1189    if !tags_json.contains(&pattern) { return None; }
1190    let tags: Vec<Vec<String>> = serde_json::from_str(tags_json).ok()?;
1191    tags.into_iter()
1192        .find(|tag| tag.first().map(|s| s.as_str()) == Some(key))
1193        .and_then(|tag| tag.into_iter().nth(1))
1194}
1195
1196
1197/// A stored reaction author written as 64-char hex (an early v2 ingest) reads
1198/// back as the npub the frontend contract expects — self-heals old rows with no
1199/// migration; a bech32 or unknown value passes through untouched.
1200fn normalize_reaction_author(author: String) -> String {
1201    if author.len() == 64 && author.bytes().all(|b| b.is_ascii_hexdigit()) {
1202        if let Ok(pk) = nostr_sdk::prelude::PublicKey::from_hex(&author) {
1203            use nostr_sdk::prelude::ToBech32;
1204            let Ok(npub) = pk.to_bech32();
1205            return npub;
1206        }
1207    }
1208    author
1209}
1210/// Extract the NIP-30 `["emoji", shortcode, url]` URL from a stored
1211/// reaction's tags. The reaction's content must be `:shortcode:` form
1212/// and the matching tag's shortcode must agree — otherwise we get the
1213/// URL of a stray emoji tag that doesn't actually represent the
1214/// reaction's chosen emoji.
1215fn extract_reaction_emoji_url(tags: &[Vec<String>], content: &str) -> Option<String> {
1216    if !content.starts_with(':') || !content.ends_with(':') || content.len() < 3 {
1217        return None;
1218    }
1219    let sc = &content[1..content.len() - 1];
1220    tags.iter().find_map(|t| {
1221        if t.len() >= 3 && t[0] == "emoji" && t[1] == sc {
1222            Some(t[2].clone())
1223        } else {
1224            None
1225        }
1226    })
1227}
1228
1229/// Extract a NIP-10 reply reference ("e" tag with "reply" marker at position 3).
1230fn extract_reply_tag_from_json(tags_json: &str) -> Option<String> {
1231    if tags_json.len() <= 2 { return None; }
1232    if !tags_json.contains("[\"e\"") { return None; }
1233    let tags: Vec<Vec<String>> = serde_json::from_str(tags_json).ok()?;
1234    tags.into_iter()
1235        .find(|tag| {
1236            tag.first().map(|s| s.as_str()) == Some("e")
1237                && tag.get(3).map(|s| s.as_str()) == Some("reply")
1238        })
1239        .and_then(|tag| tag.into_iter().nth(1))
1240}
1241
1242/// Get message events with reactions, edits, and attachments composed.
1243///
1244/// This is the main "get messages" function. Queries events, fetches related
1245/// reactions/edits, parses attachments, applies edits, resolves reply context.
1246pub async fn get_message_views(
1247    chat_id: i64,
1248    limit: usize,
1249    offset: usize,
1250) -> Result<Vec<Message>, String> {
1251    // Step 1: Get message events (kind 9, 14, 15)
1252    let message_kinds = [event_kind::CHAT_MESSAGE, event_kind::PRIVATE_DIRECT_MESSAGE, event_kind::FILE_ATTACHMENT];
1253    let message_events = get_events(chat_id, Some(&message_kinds), limit, offset).await?;
1254
1255    compose_message_views(message_events).await
1256}
1257
1258/// Compose Message views from already-fetched message events (kind 9/14/15):
1259/// fetch related reactions/edits, parse attachments, apply edits, resolve reply
1260/// context. Shared by `get_message_views` (offset pager) and `get_messages_around`
1261/// (anchored window). Input order is preserved in the output.
1262async fn compose_message_views(message_events: Vec<StoredEvent>) -> Result<Vec<Message>, String> {
1263    use std::collections::HashMap;
1264
1265    if message_events.is_empty() {
1266        return Ok(Vec::new());
1267    }
1268
1269    // Step 2: Get related events (reactions, edits)
1270    let message_ids: Vec<String> = message_events.iter().map(|e| e.id.clone()).collect();
1271    let related_events = get_related_events(&message_ids).await?;
1272
1273    let mut reactions_by_msg: HashMap<String, Vec<Reaction>> = HashMap::new();
1274    let mut edits_by_msg: HashMap<String, Vec<(u64, String, Vec<crate::types::EmojiTag>)>> = HashMap::new();
1275
1276    for event in related_events {
1277        if let Some(ref_id) = &event.reference_id {
1278            match event.kind {
1279                k if k == event_kind::REACTION => {
1280                    let emoji_url = extract_reaction_emoji_url(&event.tags, &event.content);
1281                    let reaction = Reaction {
1282                        id: event.id.clone(),
1283                        reference_id: ref_id.clone(),
1284                        author_id: normalize_reaction_author(event.npub.clone().unwrap_or_default()),
1285                        emoji: event.content.clone(),
1286                        emoji_url,
1287                    };
1288                    // Rows predating the (author, emoji) rule — or written by a client
1289                    // that never had it — must not resurrect a double count on reload.
1290                    let slot = reactions_by_msg.entry(ref_id.clone()).or_default();
1291                    if !slot.iter().any(|r| r.same_slot(&reaction)) {
1292                        slot.push(reaction);
1293                    }
1294                }
1295                k if k == event_kind::MESSAGE_EDIT => {
1296                    let decrypted = crate::crypto::maybe_decrypt(event.content.clone()).await
1297                        .unwrap_or_else(|_| event.content.clone());
1298                    let edit_emoji = crate::types::EmojiTag::extract_from_stored(&event.tags);
1299                    edits_by_msg.entry(ref_id.clone()).or_default().push((event.created_at * 1000, decrypted, edit_emoji));
1300                }
1301                _ => {}
1302            }
1303        }
1304    }
1305
1306    for edits in edits_by_msg.values_mut() {
1307        edits.sort_by_key(|(ts, _, _)| *ts);
1308    }
1309
1310    // Step 3: Attachments from the dedicated table (batched), with a legacy-tag fallback for any
1311    // file event not represented in the table (an un-backfilled pre-migration row).
1312    let attach_ids: Vec<String> = message_events.iter()
1313        .filter(|e| e.kind == event_kind::FILE_ATTACHMENT || e.kind == event_kind::CHAT_MESSAGE)
1314        .map(|e| e.id.clone())
1315        .collect();
1316    let mut attachments_by_msg = super::attachments::get_attachments_for_events(&attach_ids)
1317        .unwrap_or_default();
1318    for event in &message_events {
1319        if event.kind != event_kind::FILE_ATTACHMENT && event.kind != event_kind::CHAT_MESSAGE {
1320            continue;
1321        }
1322        if attachments_by_msg.contains_key(&event.id) {
1323            continue;
1324        }
1325        if let Some(json) = event.get_tag("attachments") {
1326            if let Ok(atts) = serde_json::from_str::<Vec<Attachment>>(json) {
1327                if !atts.is_empty() {
1328                    attachments_by_msg.insert(event.id.clone(), atts);
1329                }
1330            }
1331        }
1332    }
1333
1334    // Step 4: Compose Message structs
1335    let mut messages = Vec::with_capacity(message_events.len());
1336    for event in message_events {
1337        let replied_to = event.get_reply_reference().unwrap_or("").to_string();
1338        let at = event.timestamp_ms();
1339        let reactions = reactions_by_msg.remove(&event.id).unwrap_or_default();
1340        let attachments = attachments_by_msg.remove(&event.id).unwrap_or_default();
1341
1342        let original_content = if event.kind == event_kind::FILE_ATTACHMENT {
1343            String::new()
1344        } else {
1345            event.content.clone()
1346        };
1347
1348        // Edits carry their own emoji tags; the newest edit's tags win so the
1349        // displayed (latest) content renders its custom emoji, not the original's.
1350        let original_emoji = crate::types::EmojiTag::extract_from_stored(&event.tags);
1351        let (content, edited, edit_history, emoji_tags) = if let Some(edits) = edits_by_msg.remove(&event.id) {
1352            let mut history = Vec::with_capacity(edits.len() + 1);
1353            history.push(crate::types::EditEntry { content: original_content.clone(), edited_at: at });
1354            for (ts, c, _) in &edits {
1355                history.push(crate::types::EditEntry { content: c.clone(), edited_at: *ts });
1356            }
1357            let (latest, latest_emoji) = edits.last()
1358                .map(|(_, c, e)| (c.clone(), e.clone()))
1359                .unwrap_or_else(|| (original_content.clone(), original_emoji.clone()));
1360            (latest, true, Some(history), latest_emoji)
1361        } else {
1362            (original_content, false, None, original_emoji)
1363        };
1364
1365        let preview_metadata = event.preview_metadata
1366            .and_then(|json| serde_json::from_str(&json).ok());
1367
1368        let addressed_bots = extract_bot_tags(&event.tags);
1369        let expiration = extract_expiration_tag(&event.tags);
1370        messages.push(Message {
1371            expiration,
1372            id: event.id, content, replied_to,
1373            replied_to_content: None, replied_to_npub: None, replied_to_has_attachment: None,
1374            replied_to_attachment_extension: None, replied_to_emoji_tags: None,
1375            preview_metadata, attachments, reactions, at,
1376            pending: event.pending, failed: event.failed, mine: event.mine,
1377            npub: event.npub, wrapper_event_id: event.wrapper_event_id,
1378            edited, edit_history,
1379            emoji_tags,
1380            addressed_bots,
1381        });
1382    }
1383
1384    // Rows whose NIP-40 expiry passed while out of STATE (app closed, chat
1385    // unopened) must never reach a renderer — strip them here, at the single
1386    // DB→Message chokepoint, and purge their remnants in the background.
1387    crate::self_destruct::strip_expired(&mut messages);
1388
1389    // Step 5: Reply context
1390    let reply_ids: Vec<String> = messages.iter()
1391        .filter(|m| !m.replied_to.is_empty())
1392        .map(|m| m.replied_to.clone())
1393        .collect();
1394
1395    if !reply_ids.is_empty() {
1396        let contexts = get_reply_contexts(&reply_ids).await?;
1397        for msg in &mut messages {
1398            if let Some(ctx) = contexts.get(&msg.replied_to) {
1399                msg.replied_to_content = Some(ctx.content.clone());
1400                msg.replied_to_npub = ctx.npub.clone();
1401                msg.replied_to_has_attachment = Some(ctx.has_attachment);
1402                msg.replied_to_attachment_extension = ctx.extension.clone();
1403            }
1404        }
1405    }
1406
1407    Ok(messages)
1408}
1409
1410/// Anchored (random-access) message window: load `before` messages up to and
1411/// including the anchor, plus `after` messages strictly newer than it. O(window)
1412/// regardless of how deep the anchor sits in the chat — unlike the offset pager,
1413/// which is O(depth) to reach a far-back message.
1414///
1415/// Returns ASC by `created_at` (oldest first), composed with reactions/edits/
1416/// attachments. Errs if the anchor id isn't in the DB so the caller can fall back.
1417/// One message by its id, composed exactly as the pagers compose theirs —
1418/// attachments, reactions, edits and reply context — with the chat it was said
1419/// in. A citation names a message, never the room, so the room comes back too.
1420///
1421/// `None` is an ordinary answer: a citation outlives its message (a delete, a
1422/// moderation hide, an expiry, or simply history this account never synced),
1423/// and a resolver that errored on absence would make every one of those a fault.
1424pub async fn get_message_by_id(message_id: &str) -> Result<Option<(String, Message)>, String> {
1425    let kinds = [event_kind::CHAT_MESSAGE, event_kind::PRIVATE_DIRECT_MESSAGE, event_kind::FILE_ATTACHMENT]
1426        .map(|k| k.to_string())
1427        .join(",");
1428    let found = {
1429        let conn = super::get_db_connection_guard_static()?;
1430        // Column order matches `parse_event_row`; the chat identifier rides along at 15.
1431        let sql = format!(
1432            "SELECT e.id, e.kind, e.chat_id, e.user_id, e.content, e.tags, e.reference_id, \
1433             e.created_at, e.received_at, e.mine, e.pending, e.failed, e.wrapper_event_id, e.npub, \
1434             e.preview_metadata, c.chat_identifier \
1435             FROM events e JOIN chats c ON c.id = e.chat_id \
1436             WHERE e.id = ?1 AND e.kind IN ({kinds})"
1437        );
1438        let mut stmt = conn.prepare(&sql).map_err(|e| format!("prepare get_message_by_id: {e}"))?;
1439        stmt.query_row(rusqlite::params![message_id], |r| Ok((parse_event_row(r)?, r.get::<_, String>(15)?)))
1440            .optional()
1441            .map_err(|e| format!("query get_message_by_id: {e}"))?
1442    };
1443    let Some((event, chat_identifier)) = found else { return Ok(None) };
1444    Ok(compose_message_views(vec![event]).await?.pop().map(|m| (chat_identifier, m)))
1445}
1446
1447pub async fn get_messages_around(
1448    chat_id: i64,
1449    anchor_id: &str,
1450    before: usize,
1451    after: usize,
1452) -> Result<Vec<Message>, String> {
1453    let message_kinds = [event_kind::CHAT_MESSAGE, event_kind::PRIVATE_DIRECT_MESSAGE, event_kind::FILE_ATTACHMENT];
1454
1455    let message_events: Vec<StoredEvent> = {
1456        let conn = super::get_db_connection_guard_static()?;
1457        let community = community_of_chat(&conn, chat_id);
1458
1459        // Resolve the anchor's FULL sort key (created_at, received_at, rowid). Paging by created_at
1460        // alone wedges on a wall of equal timestamps (a message burst): the query keeps returning the
1461        // same newest-N of the cluster, so back-paging stalls before reaching older history. The
1462        // (received_at, rowid) tiebreak — rowid being the unique final key — gives a strict total
1463        // order, so every page steps strictly past the previous, through any same-timestamp cluster.
1464        let (anchor_at, anchor_rt, anchor_rowid): (i64, i64, i64) = conn.query_row(
1465            "SELECT created_at, received_at, rowid FROM events WHERE id = ?1",
1466            rusqlite::params![anchor_id],
1467            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1468        ).map_err(|e| format!("Anchor message not found: {}", e))?;
1469
1470        // Kinds occupy ?2..?4; then ?5 created_at, ?6 received_at, ?7 rowid, ?8 limit.
1471        let kind_placeholders: String = (0..message_kinds.len())
1472            .map(|i| format!("?{}", i + 2))
1473            .collect::<Vec<_>>()
1474            .join(",");
1475        let cols = "id, kind, chat_id, user_id, content, tags, reference_id, \
1476                    created_at, received_at, mine, pending, failed, wrapper_event_id, npub, preview_metadata";
1477
1478        // Older incl. anchor: strict key <= anchor key; newest-first then reverse to ASC.
1479        let older_sql = format!(
1480            "SELECT {} FROM events WHERE chat_id = ?1 AND kind IN ({}) \
1481             AND (created_at < ?5 OR (created_at = ?5 AND (received_at < ?6 \
1482                  OR (received_at = ?6 AND rowid <= ?7)))) \
1483             {} ORDER BY created_at DESC, received_at DESC, rowid DESC LIMIT ?8",
1484            cols, kind_placeholders, ban_filter_sql(9)
1485        );
1486        let mut older_stmt = conn.prepare(&older_sql)
1487            .map_err(|e| format!("Failed to prepare older window query: {}", e))?;
1488        let older_rows = older_stmt.query_map(
1489            rusqlite::params![
1490                chat_id,
1491                message_kinds[0] as i32, message_kinds[1] as i32, message_kinds[2] as i32,
1492                anchor_at, anchor_rt, anchor_rowid, before as i64, community
1493            ],
1494            parse_event_row,
1495        ).map_err(|e| format!("Failed to query older window: {}", e))?;
1496        let mut older: Vec<StoredEvent> = older_rows.filter_map(|r| r.ok()).collect();
1497        older.reverse(); // DESC -> ASC
1498
1499        // Newer: strictly after the anchor key.
1500        let newer_sql = format!(
1501            "SELECT {} FROM events WHERE chat_id = ?1 AND kind IN ({}) \
1502             AND (created_at > ?5 OR (created_at = ?5 AND (received_at > ?6 \
1503                  OR (received_at = ?6 AND rowid > ?7)))) \
1504             {} ORDER BY created_at ASC, received_at ASC, rowid ASC LIMIT ?8",
1505            cols, kind_placeholders, ban_filter_sql(9)
1506        );
1507        let mut newer_stmt = conn.prepare(&newer_sql)
1508            .map_err(|e| format!("Failed to prepare newer window query: {}", e))?;
1509        let newer_rows = newer_stmt.query_map(
1510            rusqlite::params![
1511                chat_id,
1512                message_kinds[0] as i32, message_kinds[1] as i32, message_kinds[2] as i32,
1513                anchor_at, anchor_rt, anchor_rowid, after as i64, community
1514            ],
1515            parse_event_row,
1516        ).map_err(|e| format!("Failed to query newer window: {}", e))?;
1517        let newer: Vec<StoredEvent> = newer_rows.filter_map(|r| r.ok()).collect();
1518
1519        older.into_iter().chain(newer).collect()
1520    };
1521
1522    // Decrypt message content (mirror get_events).
1523    let mut decrypted = Vec::with_capacity(message_events.len());
1524    for mut event in message_events {
1525        if event.kind == event_kind::CHAT_MESSAGE || event.kind == event_kind::PRIVATE_DIRECT_MESSAGE {
1526            event.content = crate::crypto::maybe_decrypt(event.content).await
1527                .unwrap_or_else(|_| "[Decryption failed]".to_string());
1528        }
1529        decrypted.push(event);
1530    }
1531
1532    compose_message_views(decrypted).await
1533}
1534
1535/// Get the last message for ALL chats in a single batch query.
1536/// Optimized for app startup (chat list sidebar).
1537pub async fn get_all_chats_last_messages() -> Result<std::collections::HashMap<String, Vec<Message>>, String> {
1538    use std::collections::HashMap;
1539
1540    // Step 1: Query last message per chat via correlated subquery
1541    let message_events: Vec<(String, StoredEvent, String)> = {
1542        let conn = super::get_db_connection_guard_static()?;
1543        let mut stmt = conn.prepare(
1544            "SELECT c.chat_identifier, \
1545             e.id, e.kind, e.chat_id, e.user_id, e.content, e.tags, e.reference_id, \
1546             e.created_at, e.received_at, e.mine, e.pending, e.failed, e.wrapper_event_id, e.npub, e.preview_metadata \
1547             FROM chats c JOIN events e ON e.rowid = ( \
1548                 SELECT e2.rowid FROM events e2 WHERE e2.chat_id = c.id \
1549                 AND e2.kind IN (?1, ?2, ?3) \
1550                 ORDER BY e2.created_at DESC, e2.received_at DESC LIMIT 1) \
1551             WHERE c.chat_type != 1"
1552        ).map_err(|e| format!("Failed to prepare: {}", e))?;
1553
1554        let rows = stmt.query_map(
1555            rusqlite::params![
1556                event_kind::CHAT_MESSAGE as i32,
1557                event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1558                event_kind::FILE_ATTACHMENT as i32
1559            ],
1560            |row| {
1561                let chat_id: String = row.get(0)?;
1562                let tags_json: String = row.get(6)?;
1563                let event = StoredEvent {
1564                    id: row.get(1)?, kind: row.get::<_, i32>(2)? as u16,
1565                    chat_id: row.get(3)?, user_id: row.get(4)?, content: row.get(5)?,
1566                    tags: Vec::new(), // Deferred — parsed on-demand
1567                    reference_id: row.get(7)?,
1568                    created_at: row.get::<_, i64>(8)? as u64, received_at: row.get::<_, i64>(9)? as u64,
1569                    mine: row.get::<_, i32>(10)? != 0, pending: row.get::<_, i32>(11)? != 0,
1570                    failed: row.get::<_, i32>(12)? != 0, wrapper_event_id: row.get(13)?,
1571                    npub: row.get(14)?, preview_metadata: row.get(15)?,
1572                };
1573                Ok((chat_id, event, tags_json))
1574            }
1575        ).map_err(|e| format!("Failed to query: {}", e))?;
1576        rows.filter_map(|r| r.ok()).collect()
1577    };
1578
1579    if message_events.is_empty() {
1580        return Ok(HashMap::new());
1581    }
1582
1583    // Step 2: Related events (reactions, edits)
1584    let message_ids: Vec<String> = message_events.iter().map(|(_, e, _)| e.id.clone()).collect();
1585    let related_events = get_related_events(&message_ids).await?;
1586
1587    let mut reactions_by_msg: HashMap<String, Vec<Reaction>> = HashMap::new();
1588    let mut edits_by_msg: HashMap<String, Vec<(u64, String, Vec<crate::types::EmojiTag>)>> = HashMap::new();
1589
1590    for event in related_events {
1591        if let Some(ref_id) = &event.reference_id {
1592            match event.kind {
1593                k if k == event_kind::REACTION => {
1594                    let emoji_url = extract_reaction_emoji_url(&event.tags, &event.content);
1595                    let reaction = Reaction {
1596                        id: event.id.clone(), reference_id: ref_id.clone(),
1597                        author_id: normalize_reaction_author(event.npub.clone().unwrap_or_default()),
1598                        emoji: event.content.clone(),
1599                        emoji_url,
1600                    };
1601                    // Rows predating the (author, emoji) rule — or written by a client
1602                    // that never had it — must not resurrect a double count on reload.
1603                    let slot = reactions_by_msg.entry(ref_id.clone()).or_default();
1604                    if !slot.iter().any(|r| r.same_slot(&reaction)) {
1605                        slot.push(reaction);
1606                    }
1607                }
1608                k if k == event_kind::MESSAGE_EDIT => {
1609                    let decrypted = crate::crypto::maybe_decrypt(event.content.clone()).await
1610                        .unwrap_or_else(|_| event.content.clone());
1611                    let edit_emoji = crate::types::EmojiTag::extract_from_stored(&event.tags);
1612                    edits_by_msg.entry(ref_id.clone()).or_default().push((event.created_at * 1000, decrypted, edit_emoji));
1613                }
1614                _ => {}
1615            }
1616        }
1617    }
1618    for edits in edits_by_msg.values_mut() {
1619        edits.sort_by_key(|(ts, _, _)| *ts);
1620    }
1621
1622    // Step 3: Attachments from the dedicated table (batched), with a legacy-tag fallback.
1623    let attach_ids: Vec<String> = message_events.iter()
1624        .filter(|(_, e, _)| e.kind == event_kind::FILE_ATTACHMENT || e.kind == event_kind::CHAT_MESSAGE)
1625        .map(|(_, e, _)| e.id.clone())
1626        .collect();
1627    let mut attachments_by_msg = super::attachments::get_attachments_for_events(&attach_ids)
1628        .unwrap_or_default();
1629    for (_, event, tags_json) in &message_events {
1630        if event.kind != event_kind::FILE_ATTACHMENT && event.kind != event_kind::CHAT_MESSAGE {
1631            continue;
1632        }
1633        if attachments_by_msg.contains_key(&event.id) {
1634            continue;
1635        }
1636        if let Some(val) = extract_tag_from_json(tags_json, "attachments") {
1637            if let Ok(atts) = serde_json::from_str::<Vec<Attachment>>(&val) {
1638                if !atts.is_empty() {
1639                    attachments_by_msg.insert(event.id.clone(), atts);
1640                }
1641            }
1642        }
1643    }
1644
1645    // Step 4: Compose Messages grouped by chat_identifier
1646    let mut result: HashMap<String, Vec<Message>> = HashMap::new();
1647
1648    for (chat_identifier, event, tags_json) in message_events {
1649        let reactions = reactions_by_msg.remove(&event.id).unwrap_or_default();
1650        let attachments = attachments_by_msg.remove(&event.id).unwrap_or_default();
1651        let replied_to = extract_reply_tag_from_json(&tags_json).unwrap_or_default();
1652
1653        // Decrypt content
1654        let original_content = if event.kind == event_kind::CHAT_MESSAGE
1655            || event.kind == event_kind::PRIVATE_DIRECT_MESSAGE
1656        {
1657            crate::crypto::maybe_decrypt(event.content.clone()).await
1658                .unwrap_or_else(|_| "[Decryption failed]".to_string())
1659        } else {
1660            String::new()
1661        };
1662
1663        let stored_tags = serde_json::from_str::<Vec<Vec<String>>>(&tags_json).unwrap_or_default();
1664        let original_emoji = crate::types::EmojiTag::extract_from_stored(&stored_tags);
1665        let addressed_bots = extract_bot_tags(&stored_tags);
1666        let expiration = extract_expiration_tag(&stored_tags);
1667        // Newest edit's emoji tags win so the latest content renders correctly.
1668        let (content, edited, edit_history, emoji_tags) = if let Some(edits) = edits_by_msg.remove(&event.id) {
1669            let (latest, latest_emoji) = edits.last()
1670                .map(|(_, c, e)| (c.clone(), e.clone()))
1671                .unwrap_or_else(|| (original_content.clone(), original_emoji.clone()));
1672            let history: Vec<crate::types::EditEntry> = std::iter::once(crate::types::EditEntry {
1673                content: original_content, edited_at: event.created_at * 1000,
1674            }).chain(edits.into_iter().map(|(ts, c, _)| crate::types::EditEntry { content: c, edited_at: ts }))
1675            .collect();
1676            (latest, true, Some(history), latest_emoji)
1677        } else {
1678            (original_content, false, None, original_emoji)
1679        };
1680
1681        let preview_metadata = event.preview_metadata
1682            .and_then(|json| serde_json::from_str(&json).ok());
1683
1684        result.entry(chat_identifier).or_default().push(Message {
1685            expiration,
1686            id: event.id, content, replied_to,
1687            replied_to_content: None, replied_to_npub: None, replied_to_has_attachment: None,
1688            replied_to_attachment_extension: None, replied_to_emoji_tags: None,
1689            preview_metadata, attachments, reactions, at: event.created_at * 1000,
1690            pending: event.pending, failed: event.failed, mine: event.mine,
1691            npub: event.npub, wrapper_event_id: event.wrapper_event_id,
1692            edited, edit_history,
1693            emoji_tags,
1694            addressed_bots,
1695        });
1696    }
1697
1698    // Step 5: Reply context — the openChat pre-paint renders this boot last-message
1699    // synchronously (before the richer get_message_views load lands), so without
1700    // context here a reply shows its quote only on the second open.
1701    let reply_ids: Vec<String> = result.values()
1702        .flatten()
1703        .filter(|m| !m.replied_to.is_empty())
1704        .map(|m| m.replied_to.clone())
1705        .collect();
1706
1707    if !reply_ids.is_empty() {
1708        let contexts = get_reply_contexts(&reply_ids).await?;
1709        for msg in result.values_mut().flatten() {
1710            if let Some(ctx) = contexts.get(&msg.replied_to) {
1711                msg.replied_to_content = Some(ctx.content.clone());
1712                msg.replied_to_npub = ctx.npub.clone();
1713                msg.replied_to_has_attachment = Some(ctx.has_attachment);
1714                msg.replied_to_attachment_extension = ctx.extension.clone();
1715            }
1716        }
1717    }
1718
1719    // An expired self-destruct as a chat's last message must not flash in the
1720    // chat list — strip + background-purge; the preview shows empty until the
1721    // next boot resolves the prior message, same as a mid-session sweep.
1722    for msgs in result.values_mut() {
1723        crate::self_destruct::strip_expired(msgs);
1724    }
1725
1726    Ok(result)
1727}
1728
1729/// Per-chat unread count, computed straight from the DB so it's correct even when only the last
1730/// message is in RAM (the boot state). Mirrors the in-memory walk-back exactly: unread = non-mine
1731/// messages newer than the most recent "anchor" (our own message OR the `last_read` marker,
1732/// whichever is latest). A never-read chat (empty `last_read`, no own message) counts all its
1733/// non-mine messages. Returns `chat_identifier → count`; chats with 0 unread are omitted.
1734/// CHAT-level muted/blocked filtering is left to the caller (it lives in RAM state, cheaply).
1735/// SENDER-level filtering happens here: a message whose author's DM is muted or whose profile
1736/// is blocked never counts, in any chat — muting a person silences their community messages too.
1737/// A member BANNED from a community likewise stops counting there: a ban that still badges the
1738/// channel sends the reader to look for a message the client will not render.
1739pub async fn unread_counts() -> Result<std::collections::HashMap<String, u32>, String> {
1740    let conn = super::get_db_connection_guard_static()?;
1741    // Anchor computed once per chat in the CTE so the count scan doesn't re-derive it per row. The
1742    // anchor filter rides the LEFT JOIN's ON clause so a never-read chat still yields a row (anchor
1743    // 0 via COALESCE) and counts all its messages; in WHERE it would drop those chats. The
1744    // `last_read` anchor is kind-agnostic: a "read to here" marker can land on a system event (kind
1745    // 30078) and must still cut the count by its timestamp. Only the own-message anchor is kind-filtered.
1746    let mut stmt = conn
1747        .prepare(
1748            "WITH anchors AS ( \
1749                SELECT c.id AS chat_id, c.chat_identifier AS chat_identifier, \
1750                       COALESCE(MAX(e.created_at), 0) AS anchor_ts \
1751                FROM chats c \
1752                LEFT JOIN events e ON e.chat_id = c.id \
1753                  AND ((e.mine = 1 AND e.kind IN (?1, ?2, ?3)) OR e.id = c.last_read) \
1754                GROUP BY c.id \
1755             ) \
1756             SELECT a.chat_identifier, COUNT(*) AS unread \
1757             FROM events e JOIN anchors a ON a.chat_id = e.chat_id \
1758             WHERE e.kind IN (?1, ?2, ?3) AND e.mine = 0 AND e.created_at > a.anchor_ts \
1759               AND (e.npub IS NULL OR e.npub NOT IN ( \
1760                     SELECT chat_identifier FROM chats WHERE muted = 1 \
1761                     UNION \
1762                     SELECT npub FROM profiles WHERE is_blocked = 1)) \
1763               AND NOT EXISTS ( \
1764                     SELECT 1 FROM community_channels cc \
1765                     JOIN community_bans b ON b.community_id = cc.community_id AND b.npub = e.npub \
1766                     WHERE cc.channel_id = a.chat_identifier) \
1767             GROUP BY a.chat_identifier",
1768        )
1769        .map_err(|e| format!("prepare unread_counts: {e}"))?;
1770    let rows = stmt
1771        .query_map(
1772            rusqlite::params![
1773                event_kind::CHAT_MESSAGE as i32,
1774                event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1775                event_kind::FILE_ATTACHMENT as i32
1776            ],
1777            |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u32)),
1778        )
1779        .map_err(|e| format!("query unread_counts: {e}"))?;
1780    let mut out = std::collections::HashMap::new();
1781    for r in rows.flatten() {
1782        out.insert(r.0, r.1);
1783    }
1784    Ok(out)
1785}
1786
1787/// Unread count for a SINGLE chat, same semantics as [`unread_counts`]. The RAM cache calls this to
1788/// reconcile one chat (open / delete / retreat) without recomputing every chat's count.
1789pub async fn unread_count_for_chat(chat_identifier: &str) -> Result<u32, String> {
1790    let conn = super::get_db_connection_guard_static()?;
1791    let count: i64 = conn
1792        .query_row(
1793            "SELECT COUNT(*) FROM events e JOIN chats c ON e.chat_id = c.id \
1794             WHERE c.chat_identifier = ?4 AND e.kind IN (?1, ?2, ?3) AND e.mine = 0 \
1795               AND (e.npub IS NULL OR e.npub NOT IN ( \
1796                     SELECT chat_identifier FROM chats WHERE muted = 1 \
1797                     UNION \
1798                     SELECT npub FROM profiles WHERE is_blocked = 1)) \
1799               AND NOT EXISTS ( \
1800                     SELECT 1 FROM community_channels cc \
1801                     JOIN community_bans b ON b.community_id = cc.community_id AND b.npub = e.npub \
1802                     WHERE cc.channel_id = c.chat_identifier) \
1803               AND e.created_at > COALESCE(( \
1804                     SELECT MAX(e2.created_at) FROM events e2 \
1805                     WHERE e2.chat_id = c.id \
1806                       AND ((e2.mine = 1 AND e2.kind IN (?1, ?2, ?3)) OR e2.id = c.last_read)), 0)",
1807            rusqlite::params![
1808                event_kind::CHAT_MESSAGE as i32,
1809                event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1810                event_kind::FILE_ATTACHMENT as i32,
1811                chat_identifier
1812            ],
1813            |row| row.get(0),
1814        )
1815        .map_err(|e| format!("query unread_count_for_chat: {e}"))?;
1816    Ok(count as u32)
1817}
1818
1819/// What [`compute_unread_anchor`] decided a chat's read marker should become to surface its newest
1820/// contact message as unread. Computed from the full DB history (RAM may hold only a preview
1821/// message for an unopened community).
1822#[derive(Debug, PartialEq)]
1823pub enum UnreadMark {
1824    /// Nothing to surface: no contact message, or a strictly-newer own message (we spoke last).
1825    NoOp,
1826    /// Reset to the never-read anchor: the target is the chat's earliest message.
1827    Clear,
1828    /// Retreat `last_read` to this event id (the newest message in a strictly earlier second).
1829    Anchor(String),
1830}
1831
1832/// Decide how to mark `chat_identifier` unread. Anchors on the newest message strictly before the
1833/// newest contact message's second — the count query compares whole seconds with a strict `>`, so a
1834/// same-second anchor would leave the target on the boundary and it would read as caught-up.
1835pub async fn compute_unread_anchor(chat_identifier: &str) -> Result<UnreadMark, String> {
1836    let conn = super::get_db_connection_guard_static()?;
1837    let (k0, k1, k2) = (
1838        event_kind::CHAT_MESSAGE as i32,
1839        event_kind::PRIVATE_DIRECT_MESSAGE as i32,
1840        event_kind::FILE_ATTACHMENT as i32,
1841    );
1842    // Newest non-mine message second (the target) and newest overall (to detect we spoke last).
1843    let (target_ts, newest_ts): (Option<i64>, Option<i64>) = conn
1844        .query_row(
1845            "SELECT MAX(CASE WHEN e.mine = 0 THEN e.created_at END), MAX(e.created_at) \
1846             FROM events e JOIN chats c ON e.chat_id = c.id \
1847             WHERE c.chat_identifier = ?1 AND e.kind IN (?2, ?3, ?4)",
1848            rusqlite::params![chat_identifier, k0, k1, k2],
1849            |row| Ok((row.get(0)?, row.get(1)?)),
1850        )
1851        .map_err(|e| format!("unread anchor target: {e}"))?;
1852
1853    let target_ts = match target_ts {
1854        Some(t) => t,
1855        None => return Ok(UnreadMark::NoOp), // no contact message to surface
1856    };
1857    if newest_ts.map_or(false, |n| n > target_ts) {
1858        return Ok(UnreadMark::NoOp); // a strictly-newer own message → we spoke last
1859    }
1860
1861    let anchor_id: Option<String> = conn
1862        .query_row(
1863            "SELECT e.id FROM events e JOIN chats c ON e.chat_id = c.id \
1864             WHERE c.chat_identifier = ?1 AND e.kind IN (?2, ?3, ?4) AND e.created_at < ?5 \
1865             ORDER BY e.created_at DESC LIMIT 1",
1866            rusqlite::params![chat_identifier, k0, k1, k2, target_ts],
1867            |row| row.get(0),
1868        )
1869        .optional()
1870        .map_err(|e| format!("unread anchor prev: {e}"))?;
1871
1872    Ok(match anchor_id {
1873        Some(id) => UnreadMark::Anchor(id),
1874        None => UnreadMark::Clear,
1875    })
1876}
1877
1878/// Drain a sync loop's pending message batch into one transaction. The shared flush for the
1879/// segment-flush pattern: bulk loops COLLECT message saves and call this at delete barriers +
1880/// loop end (a batched save committing AFTER a delete it originally preceded would resurrect
1881/// the deleted row — flushing first preserves wire order). Session-guarded HERE so every bulk
1882/// path gets the same swap-safety: on a stale session the batch is DROPPED, never written
1883/// into the next account's DB (the caller's loop is about to bail anyway).
1884pub async fn flush_message_batch(
1885    chat_id: &str,
1886    pending: &mut Vec<&Message>,
1887    session: &std::sync::Arc<crate::db::Session>,
1888) {
1889    crate::db::scoped(async move {
1890        if pending.is_empty() {
1891            return;
1892        }
1893        if !session.is_live() {
1894            pending.clear();
1895            return;
1896        }
1897        if let Err(e) = save_messages_batch(chat_id, pending).await {
1898            crate::log_warn!("[DB] batch flush failed for {}: {}", chat_id, e);
1899        }
1900        pending.clear();
1901    })
1902    .await
1903}
1904
1905/// Batch save messages for a chat — one transaction for the whole slice.
1906pub async fn save_chat_messages(chat_id: &str, messages: &[Message]) -> Result<(), String> {
1907    if messages.is_empty() {
1908        return Ok(());
1909    }
1910    let refs: Vec<&Message> = messages.iter().collect();
1911    save_messages_batch(chat_id, &refs).await.map(|_| ())
1912}
1913
1914#[cfg(test)]
1915mod tests {
1916    use super::*;
1917    use crate::stored_event::SystemEventType;
1918
1919    static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(71000);
1920
1921    fn make_test_npub(n: u32) -> String {
1922        const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
1923        let mut payload = vec![b'q'; 58];
1924        let mut x = n as u64;
1925        let mut i = 58;
1926        while x > 0 && i > 0 {
1927            i -= 1;
1928            payload[i] = BECH32[(x as usize) % 32];
1929            x /= 32;
1930        }
1931        format!("npub1{}", std::str::from_utf8(&payload).unwrap())
1932    }
1933
1934    fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
1935        let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
1936        crate::db::close_database();
1937        // Each test rebinds to a fresh per-account DB; the row-id caches are per-account, so a stale
1938        // entry (e.g. a shared author npub) would point into the prior test's DB and FK-fail the insert.
1939        crate::db::clear_id_caches();
1940        let tmp = tempfile::tempdir().unwrap();
1941        let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1942        let account = make_test_npub(n);
1943        std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
1944        crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
1945        crate::db::set_current_account(account.clone()).unwrap();
1946        crate::db::init_database(&account).unwrap();
1947        (tmp, guard)
1948    }
1949
1950    fn now_secs() -> u64 {
1951        std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
1952    }
1953
1954    /// A page of synced replies must come out of the batch resolver carrying their quotes.
1955    /// The sync/promote paths emit straight to the UI, and the renderer falls back to the
1956    /// in-memory parent only when the parent is inside the rendered window — so a reply to
1957    /// an OLDER message renders with no quote at all unless the backend resolved it here.
1958    /// Reopening the chat re-reads through `get_message_views` (which resolves), which is
1959    /// why the bug looked like "replies lose their context until you reopen".
1960    #[tokio::test]
1961    async fn batch_resolver_fills_quotes_for_a_synced_page() {
1962        let (_tmp, _guard) = init_test_db();
1963        let chat = "channel_reply_batch";
1964        let author = make_test_npub(90_001);
1965
1966        // An older, already-persisted parent (the case with no in-memory fallback).
1967        let mut parent = Message::default();
1968        parent.id = "parent_evt".to_string();
1969        parent.content = "the original message".to_string();
1970        parent.npub = Some(author.clone());
1971        parent.at = now_secs() - 5_000;
1972        save_message(chat, &parent).await.unwrap();
1973
1974        // The freshly-synced page: a reply to it, plus an unrelated message.
1975        let mut reply = Message::default();
1976        reply.id = "reply_evt".to_string();
1977        reply.content = "replying now".to_string();
1978        reply.replied_to = "parent_evt".to_string();
1979        reply.at = now_secs();
1980        let mut plain = Message::default();
1981        plain.id = "plain_evt".to_string();
1982        plain.content = "unrelated".to_string();
1983        plain.at = now_secs();
1984        // A reply whose parent this device has never seen must stay empty, not fabricate a quote.
1985        let mut orphan = Message::default();
1986        orphan.id = "orphan_evt".to_string();
1987        orphan.replied_to = "never_seen_evt".to_string();
1988        orphan.at = now_secs();
1989
1990        populate_reply_contexts(vec![&mut reply, &mut plain, &mut orphan]).await.unwrap();
1991
1992        assert_eq!(
1993            reply.replied_to_content.as_deref(),
1994            Some("the original message"),
1995            "the synced reply carries its parent's content"
1996        );
1997        assert_eq!(reply.replied_to_npub.as_deref(), Some(author.as_str()), "and its parent's author");
1998        assert!(plain.replied_to_content.is_none(), "a non-reply is untouched");
1999        assert!(orphan.replied_to_content.is_none(), "an unresolvable parent leaves the quote empty");
2000    }
2001
2002    /// A moderation citation names a message id and nothing else, so the room
2003    /// has to come back with it.
2004    #[tokio::test]
2005    async fn a_message_resolves_by_id_with_the_chat_it_was_said_in() {
2006        let (_tmp, _guard) = init_test_db();
2007        let chat = "channel_resolve_by_id";
2008        let author = make_test_npub(91_001);
2009
2010        let mut msg = Message::default();
2011        msg.id = "cited_evt".to_string();
2012        msg.content = "the line a rule matched".to_string();
2013        msg.npub = Some(author.clone());
2014        msg.at = now_secs();
2015        save_message(chat, &msg).await.unwrap();
2016
2017        let (found_chat, found) = get_message_by_id("cited_evt").await.unwrap().expect("stored message resolves");
2018        assert_eq!(found_chat, chat, "the citation gets the room back, not just the message");
2019        assert_eq!(found.content, "the line a rule matched", "and the text, so a mod log can quote it");
2020        assert_eq!(found.npub.as_deref(), Some(author.as_str()));
2021    }
2022
2023    /// A citation outlives its message: deletes, moderation hides, expiry, or
2024    /// history this device never synced. Absence is an answer, not a fault — a
2025    /// resolver that errored would turn every one of those into a failure.
2026    #[tokio::test]
2027    async fn a_missing_message_resolves_to_none_rather_than_erroring() {
2028        let (_tmp, _guard) = init_test_db();
2029        assert!(get_message_by_id("never_stored_evt").await.unwrap().is_none());
2030    }
2031
2032    /// One blob rides many messages — a forward, a re-post, or forty accounts
2033    /// posting the same image, which is the case most worth seeing whole.
2034    #[tokio::test]
2035    async fn an_attachment_hash_finds_every_message_that_carried_it() {
2036        let (_tmp, _guard) = init_test_db();
2037        let chat = "channel_same_blob";
2038        let hash = "cc".repeat(32);
2039
2040        // `Message.at` is MILLISECONDS: seconds-apart values collapse to one
2041        // created_at and leave the order to chance.
2042        let now_ms = now_secs() * 1000;
2043        for (id, at) in [("blob_older", now_ms - 500_000), ("blob_newer", now_ms)] {
2044            let mut msg = Message::default();
2045            msg.id = id.to_string();
2046            msg.npub = Some(make_test_npub(92_001));
2047            msg.at = at;
2048            msg.attachments = vec![crate::types::Attachment {
2049                id: hash.clone(),
2050                extension: "png".into(),
2051                name: "poster.png".into(),
2052                ..Default::default()
2053            }];
2054            save_message(chat, &msg).await.unwrap();
2055        }
2056
2057        let carriers = crate::db::attachments::events_with_attachment_hash(&hash).unwrap();
2058        assert_eq!(carriers, vec!["blob_newer", "blob_older"], "every carrier, newest first");
2059
2060        // Same instant, which is what a raid looks like: the order must still be
2061        // total rather than whatever the join happens to yield.
2062        for id in ["tie_a", "tie_b"] {
2063            let mut msg = Message::default();
2064            msg.id = id.to_string();
2065            msg.npub = Some(make_test_npub(92_002));
2066            msg.at = now_ms;
2067            msg.attachments = vec![crate::types::Attachment { id: hash.clone(), ..Default::default() }];
2068            save_message(chat, &msg).await.unwrap();
2069        }
2070        let a = crate::db::attachments::events_with_attachment_hash(&hash).unwrap();
2071        let b = crate::db::attachments::events_with_attachment_hash(&hash).unwrap();
2072        assert_eq!(a, b, "a same-instant cluster orders the same way every read");
2073        assert_eq!(a.len(), 4, "and every carrier is still listed");
2074        assert!(
2075            crate::db::attachments::events_with_attachment_hash(&"ab".repeat(32)).unwrap().is_empty(),
2076            "a hash nobody posted finds nothing"
2077        );
2078    }
2079
2080    /// An empty page (or one with no replies at all) must not query or error — the sync path
2081    /// calls this for every page, most of which carry no replies.
2082    #[tokio::test]
2083    async fn batch_resolver_is_a_noop_without_replies() {
2084        let (_tmp, _guard) = init_test_db();
2085        populate_reply_contexts(vec![]).await.unwrap();
2086        let mut plain = Message::default();
2087        plain.id = "solo".to_string();
2088        populate_reply_contexts(vec![&mut plain]).await.unwrap();
2089        assert!(plain.replied_to_content.is_none());
2090    }
2091
2092    // C-H2: a presence (join/leave) persisted from HISTORY must keep its authenticated timestamp so it
2093    // sorts where it happened, not at ingest-time "now"; a future-dated one is clamped so it can't jump
2094    // ahead of real activity.
2095    #[tokio::test]
2096    async fn system_event_stamps_authenticated_time_clamped_to_now() {
2097        let (_tmp, _guard) = init_test_db();
2098        let chat = "channel_ch2_timestamp";
2099        let before = now_secs();
2100        let past = before - 100_000;
2101
2102        save_system_event_at("ev_past", chat, SystemEventType::MemberJoined, "npubX", None, past, None, None).await.unwrap();
2103        save_system_event_at("ev_future", chat, SystemEventType::MemberJoined, "npubX", None, before + 100_000, None, None).await.unwrap();
2104        let after = now_secs();
2105
2106        let evs = get_system_events_for_chat(chat).unwrap();
2107        let past_ev = evs.iter().find(|e| e.id == "ev_past").expect("past event saved");
2108        assert_eq!(past_ev.created_at, past, "historical join keeps its real (authenticated) timestamp");
2109
2110        let fut_ev = evs.iter().find(|e| e.id == "ev_future").expect("future event saved");
2111        assert!(fut_ev.created_at >= before && fut_ev.created_at <= after,
2112            "future-dated event clamped to local now ({} not in {}..={})", fut_ev.created_at, before, after);
2113    }
2114
2115    // Delete-affordance resolution must work on paged-out rows: the events table is the
2116    // fallback source for (chat, mine, author) when a message isn't STATE-resident.
2117    #[tokio::test]
2118    async fn event_delete_context_resolves_from_db() {
2119        let (_tmp, _guard) = init_test_db();
2120        let chat = "npub1contactdc";
2121
2122        let mine_msg = Message { id: "dc_mine".into(), content: "x".into(), at: 1_000, mine: true, ..Default::default() };
2123        let theirs = Message {
2124            id: "dc_theirs".into(), content: "y".into(), at: 2_000, mine: false,
2125            npub: Some("npub1sender".to_string()),
2126            ..Default::default()
2127        };
2128        save_message(chat, &mine_msg).await.unwrap();
2129        save_message(chat, &theirs).await.unwrap();
2130
2131        let (chat_id, mine, _author) = event_delete_context("dc_mine").unwrap().expect("own row resolves");
2132        assert_eq!(chat_id, chat);
2133        assert!(mine);
2134
2135        let (chat_id, mine, author) = event_delete_context("dc_theirs").unwrap().expect("contact row resolves");
2136        assert_eq!(chat_id, chat);
2137        assert!(!mine);
2138        assert_eq!(author.as_deref(), Some("npub1sender"));
2139
2140        assert!(event_delete_context("dc_absent").unwrap().is_none(), "unknown id is None, not an error");
2141    }
2142
2143    // The reported bug: unread must accumulate across a restart (when only the last message per
2144    // chat is in RAM). The DB cutoff count must mirror the in-memory walk-back exactly.
2145    #[tokio::test]
2146    async fn unread_counts_match_walk_back_semantics() {
2147        let (_tmp, _guard) = init_test_db();
2148        let chat = "npub1contactdm";
2149        // `at` is ms (created_at = at/1000); use distinct seconds.
2150        let mk = |id: &str, secs: u64, mine: bool| Message {
2151            id: id.into(), content: "x".into(), at: secs * 1000, mine,
2152            npub: (!mine).then(|| "npub1sender".to_string()),
2153            ..Default::default()
2154        };
2155        let unread = || async { unread_counts().await.unwrap().get(chat).copied().unwrap_or(0) };
2156
2157        // 6 contact messages, never opened/read, no own reply → all 6 unread.
2158        for i in 0..6u64 {
2159            save_message(chat, &mk(&format!("m{i}"), 1000 + i, false)).await.unwrap();
2160        }
2161        assert_eq!(unread().await, 6, "never-read backlog counts all 6");
2162
2163        // 2 more arrive → 8, NOT replaced by 2 (the exact reported symptom).
2164        save_message(chat, &mk("m6", 2000, false)).await.unwrap();
2165        save_message(chat, &mk("m7", 2001, false)).await.unwrap();
2166        assert_eq!(unread().await, 8, "6 backlog + 2 new = 8");
2167
2168        // Our own reply clears it (walk-back stops at the newest mine).
2169        save_message(chat, &mk("mine", 2002, true)).await.unwrap();
2170        assert_eq!(unread().await, 0, "own message = read up to here");
2171
2172        // A contact message after our send is unread again.
2173        save_message(chat, &mk("m8", 2003, false)).await.unwrap();
2174        assert_eq!(unread().await, 1, "one new after our send");
2175
2176        // last_read marker advances the cutoff just like an own message.
2177        {
2178            let conn = crate::db::get_write_connection_guard_static().unwrap();
2179            conn.execute(
2180                "UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
2181                rusqlite::params!["m8", chat],
2182            ).unwrap();
2183        }
2184        assert_eq!(unread().await, 0, "last_read=m8 clears all");
2185        save_message(chat, &mk("m9", 2004, false)).await.unwrap();
2186        assert_eq!(unread().await, 1, "one arrival after last_read");
2187    }
2188
2189    // Muting a person (their DM row) or blocking them silences their messages in EVERY chat's
2190    // count — community messages from a muted sender must not badge.
2191    #[tokio::test]
2192    async fn unread_counts_exclude_muted_and_blocked_senders() {
2193        let (_tmp, _guard) = init_test_db();
2194        let chat = "channel_mute_exclusion";
2195        let mk = |id: &str, secs: u64, npub: &str| Message {
2196            id: id.into(), content: "x".into(), at: secs * 1000, mine: false,
2197            npub: Some(npub.to_string()),
2198            ..Default::default()
2199        };
2200        save_message(chat, &mk("n1", 1000, "npub1noisy")).await.unwrap();
2201        save_message(chat, &mk("q1", 1001, "npub1quiet")).await.unwrap();
2202
2203        let map = || async { unread_counts().await.unwrap().get(chat).copied().unwrap_or(0) };
2204        let one = || async { unread_count_for_chat(chat).await.unwrap() };
2205        assert_eq!(map().await, 2, "both senders count while neither is muted");
2206        assert_eq!(one().await, 2);
2207
2208        // Mute npub1noisy: their (message-less) DM row exists purely to carry the flag.
2209        {
2210            let conn = crate::db::get_write_connection_guard_static().unwrap();
2211            conn.execute(
2212                "INSERT INTO chats (chat_identifier, chat_type, participants, created_at, muted) \
2213                 VALUES ('npub1noisy', 0, '', 1000, 1)",
2214                [],
2215            ).unwrap();
2216        }
2217        assert_eq!(map().await, 1, "muted sender's message stops counting");
2218        assert_eq!(one().await, 1);
2219
2220        // Block npub1quiet: same exclusion via the profiles flag.
2221        {
2222            let conn = crate::db::get_write_connection_guard_static().unwrap();
2223            conn.execute(
2224                "INSERT INTO profiles (npub, is_blocked) VALUES ('npub1quiet', 1) \
2225                 ON CONFLICT(npub) DO UPDATE SET is_blocked = 1",
2226                [],
2227            ).unwrap();
2228        }
2229        assert_eq!(map().await, 0, "blocked sender's message stops counting too");
2230        assert_eq!(one().await, 0);
2231
2232        // Unmute → the count comes straight back (nothing was consumed).
2233        {
2234            let conn = crate::db::get_write_connection_guard_static().unwrap();
2235            conn.execute("UPDATE chats SET muted = 0 WHERE chat_identifier = 'npub1noisy'", []).unwrap();
2236        }
2237        assert_eq!(map().await, 1, "unmuting restores the sender's count");
2238        assert_eq!(one().await, 1);
2239    }
2240
2241    // The single-chat reconcile query must agree with the full map for every state the cache
2242    // reconciles from (never-read, own-reply cutoff, last_read marker).
2243    #[tokio::test]
2244    async fn unread_count_for_chat_matches_the_map() {
2245        let (_tmp, _guard) = init_test_db();
2246        let chat = "npub1reconcile";
2247        let mk = |id: &str, secs: u64, mine: bool| Message {
2248            id: id.into(), content: "x".into(), at: secs * 1000, mine,
2249            npub: (!mine).then(|| "npub1sender".to_string()),
2250            ..Default::default()
2251        };
2252        let agree = || async {
2253            let map = unread_counts().await.unwrap().get(chat).copied().unwrap_or(0);
2254            let one = unread_count_for_chat(chat).await.unwrap();
2255            assert_eq!(map, one, "single-chat query diverged from the map");
2256            one
2257        };
2258
2259        for i in 0..4u64 { save_message(chat, &mk(&format!("m{i}"), 1000 + i, false)).await.unwrap(); }
2260        assert_eq!(agree().await, 4);
2261        save_message(chat, &mk("mine", 1010, true)).await.unwrap();
2262        assert_eq!(agree().await, 0);
2263        save_message(chat, &mk("after", 1011, false)).await.unwrap();
2264        assert_eq!(agree().await, 1);
2265        {
2266            let conn = crate::db::get_write_connection_guard_static().unwrap();
2267            conn.execute("UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
2268                rusqlite::params!["after", chat]).unwrap();
2269        }
2270        assert_eq!(agree().await, 0);
2271        // A chat with nothing at all reconciles to 0 (no row in the map).
2272        assert_eq!(unread_count_for_chat("npub1nonexistent").await.unwrap(), 0);
2273    }
2274
2275    #[tokio::test]
2276    async fn attachments_table_round_trip_dedup_and_cascade() {
2277        let (_tmp, _guard) = init_test_db();
2278        // downloaded:false mirrors a freshly-received attachment (Attachment::default is downloaded:true).
2279        let att = |id: &str, name: &str| Attachment {
2280            id: id.into(), url: format!("https://blossom/{id}"), name: name.into(),
2281            extension: "png".into(), size: 42, downloaded: false, ..Default::default()
2282        };
2283        let msg = |mid: &str, secs: u64, atts: Vec<Attachment>| Message {
2284            id: mid.into(), content: String::new(), at: secs * 1000, mine: false,
2285            npub: Some("npub1sender".into()), attachments: atts, ..Default::default()
2286        };
2287
2288        // Save a message with two attachments → both rows, order preserved by att_index.
2289        save_message("npub1att", &msg("m1", 1000, vec![att("hashA", "a.png"), att("hashB", "b.png")])).await.unwrap();
2290        let got = crate::db::attachments::get_attachments_for_event("m1").unwrap();
2291        assert_eq!(got.len(), 2);
2292        assert_eq!((got[0].id.as_str(), got[1].id.as_str()), ("hashA", "hashB"), "att_index order");
2293        assert_eq!(got[0].name, "a.png");
2294        assert_eq!(got[0].size, 42);
2295        assert!(!got[0].downloaded);
2296
2297        // Single-row download flip (no read-modify-write of a blob).
2298        crate::db::attachments::set_attachment_downloaded("m1", "hashA", true, "/tmp/a.png").unwrap();
2299        let got = crate::db::attachments::get_attachments_for_event("m1").unwrap();
2300        assert!(got[0].downloaded && got[0].path == "/tmp/a.png");
2301        assert!(!got[1].downloaded, "sibling attachment untouched");
2302
2303        // Dedup: a second message shares hashA → backfill-by-hash marks it (indexed, no LIKE scan).
2304        save_message("npub1att", &msg("m2", 1001, vec![att("hashA", "a-again.png")])).await.unwrap();
2305        let affected = crate::db::attachments::backfill_downloaded_by_hash("hashA", "/tmp/a.png", "m1").unwrap();
2306        assert_eq!(affected, vec!["m2".to_string()]);
2307        assert!(crate::db::attachments::get_attachments_for_event("m2").unwrap()[0].downloaded);
2308
2309        // The write funnel dual-populates the legacy tag, so get_message_views (still tag-backed
2310        // during shadow-populate) composes the attachments onto the Message.
2311        let chat_int = crate::db::id_cache::get_or_create_chat_id("npub1att").unwrap();
2312        let views = get_message_views(chat_int, 10, 0).await.unwrap();
2313        let m1 = views.iter().find(|m| m.id == "m1").unwrap();
2314        assert_eq!(m1.attachments.len(), 2);
2315
2316        // Cascade: deleting the event removes its attachment rows.
2317        delete_event("m1").await.unwrap();
2318        assert!(crate::db::attachments::get_attachments_for_event("m1").unwrap().is_empty(), "ON DELETE CASCADE");
2319    }
2320
2321    // The download persist path: a re-save must never DOWNGRADE download state (relay re-delivery),
2322    // but a re-save carrying a completed download (+ the nonce→content-hash id rewrite) must persist.
2323    #[tokio::test]
2324    async fn attachment_download_state_is_monotonic_across_resaves() {
2325        let (_tmp, _guard) = init_test_db();
2326        let att = |id: &str, downloaded: bool, path: &str| Attachment {
2327            id: id.into(), url: "u".into(), name: "f.png".into(), extension: "png".into(),
2328            size: 1, downloaded, path: path.into(), ..Default::default()
2329        };
2330        let msg = |atts: Vec<Attachment>| Message {
2331            id: "dl1".into(), content: String::new(), at: 1_000_000, mine: false,
2332            npub: Some("npub1s".into()), attachments: atts, ..Default::default()
2333        };
2334
2335        // Receive (not downloaded), then the user downloads → single-row flip.
2336        save_message("npub1dl", &msg(vec![att("nonceid", false, "")])).await.unwrap();
2337        crate::db::attachments::set_attachment_downloaded("dl1", "nonceid", true, "/tmp/f.png").unwrap();
2338
2339        // Relay re-delivery (downloaded=false) must NOT reset the download.
2340        save_message("npub1dl", &msg(vec![att("nonceid", false, "")])).await.unwrap();
2341        let got = crate::db::attachments::get_attachments_for_event("dl1").unwrap();
2342        assert!(got[0].downloaded && got[0].path == "/tmp/f.png", "re-delivery preserves the download");
2343
2344        // Post-download re-save: id rewritten nonce→content-hash, downloaded=true persists in one pass.
2345        save_message("npub1dl", &msg(vec![att("contenthash", true, "/tmp/f.png")])).await.unwrap();
2346        let got = crate::db::attachments::get_attachments_for_event("dl1").unwrap();
2347        assert_eq!(got[0].id, "contenthash", "hash rewritten nonce→content");
2348        assert!(got[0].downloaded && got[0].path == "/tmp/f.png");
2349
2350        // A re-delivery AFTER the rewrite must keep the content-hash key (not revert to the nonce),
2351        // so the hash-keyed download/backfill/clear helpers still resolve the row.
2352        save_message("npub1dl", &msg(vec![att("nonceid", false, "")])).await.unwrap();
2353        let got = crate::db::attachments::get_attachments_for_event("dl1").unwrap();
2354        assert_eq!(got[0].id, "contenthash", "content-hash key survives a later re-delivery");
2355        assert!(got[0].downloaded && got[0].path == "/tmp/f.png");
2356    }
2357
2358    // An un-backfilled pre-migration event (attachments only in the legacy tag, no table row) still
2359    // renders via the read fallback.
2360    #[tokio::test]
2361    async fn attachments_fall_back_to_legacy_tag_when_table_empty() {
2362        let (_tmp, _guard) = init_test_db();
2363        let a = Attachment {
2364            id: "tagonly".into(), url: "u".into(), name: "old.png".into(), extension: "png".into(),
2365            size: 7, downloaded: false, ..Default::default()
2366        };
2367        save_message("npub1old", &Message {
2368            id: "old1".into(), content: String::new(), at: 2_000_000, mine: false,
2369            npub: Some("npub1s".into()), attachments: vec![a.clone()], ..Default::default()
2370        }).await.unwrap();
2371
2372        // Simulate the pre-migration shape: drop the table rows, write the legacy tag onto the event.
2373        {
2374            let conn = crate::db::get_write_connection_guard_static().unwrap();
2375            conn.execute("DELETE FROM attachments WHERE event_id='old1'", []).unwrap();
2376            let inner = serde_json::to_string(&vec![a]).unwrap();
2377            let tags = serde_json::to_string(&vec![vec!["attachments".to_string(), inner]]).unwrap();
2378            conn.execute("UPDATE events SET tags=?1 WHERE id='old1'", rusqlite::params![tags]).unwrap();
2379        }
2380        assert!(crate::db::attachments::get_attachments_for_event("old1").unwrap().is_empty(), "table empty");
2381
2382        let chat_int = crate::db::id_cache::get_or_create_chat_id("npub1old").unwrap();
2383        let views = get_message_views(chat_int, 10, 0).await.unwrap();
2384        let old = views.iter().find(|m| m.id == "old1").unwrap();
2385        assert_eq!(old.attachments.len(), 1, "attachment served from the legacy-tag fallback");
2386        assert_eq!(old.attachments[0].name, "old.png");
2387    }
2388
2389    // Migration 75's strip logic: remove the legacy attachments tag ONLY from events that are
2390    // provably backfilled (have a table row); un-backfilled events keep their tag as the fallback.
2391    #[tokio::test]
2392    async fn attachment_tag_strip_is_gated_on_backfill() {
2393        let (_tmp, _guard) = init_test_db();
2394        let a = Attachment { id: "h1".into(), extension: "png".into(), name: "f.png".into(), downloaded: false, ..Default::default() };
2395        let mk = |id: &str, secs: u64| Message {
2396            id: id.into(), content: String::new(), at: secs * 1000, mine: false,
2397            npub: Some("npub1s".into()), attachments: vec![a.clone()], ..Default::default()
2398        };
2399        save_message("npub1s", &mk("bf", 1000)).await.unwrap();
2400        save_message("npub1s", &mk("unbf", 2000)).await.unwrap();
2401
2402        {
2403            let conn = crate::db::get_write_connection_guard_static().unwrap();
2404            let inner = serde_json::to_string(&vec![a.clone()]).unwrap();
2405            let with_tag = |ms: &str| serde_json::to_string(&vec![
2406                vec!["ms".to_string(), ms.to_string()],
2407                vec!["attachments".to_string(), inner.clone()],
2408            ]).unwrap();
2409            // Both events carry a legacy tag; only `unbf` loses its table rows (un-backfilled).
2410            conn.execute("UPDATE events SET tags=?1 WHERE id='bf'", rusqlite::params![with_tag("5")]).unwrap();
2411            conn.execute("UPDATE events SET tags=?1 WHERE id='unbf'", rusqlite::params![with_tag("6")]).unwrap();
2412            conn.execute("DELETE FROM attachments WHERE event_id='unbf'", []).unwrap();
2413
2414            // Replicate migration 75's gated strip.
2415            let events: Vec<(String, String)> = {
2416                let mut stmt = conn.prepare("SELECT id, tags FROM events WHERE tags LIKE '%attachments%' AND id IN (SELECT DISTINCT event_id FROM attachments)").unwrap();
2417                let m = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))).unwrap();
2418                m.flatten().collect()
2419            };
2420            for (id, tj) in events {
2421                let mut tags: Vec<Vec<String>> = serde_json::from_str(&tj).unwrap();
2422                tags.retain(|t| t.first().map(|s| s.as_str()) != Some("attachments"));
2423                conn.execute("UPDATE events SET tags=?1 WHERE id=?2",
2424                    rusqlite::params![serde_json::to_string(&tags).unwrap(), id]).unwrap();
2425            }
2426
2427            let bf: String = conn.query_row("SELECT tags FROM events WHERE id='bf'", [], |r| r.get(0)).unwrap();
2428            assert!(!bf.contains("attachments"), "backfilled event's attachments tag stripped");
2429            assert!(bf.contains("\"ms\""), "sibling ms tag survives the strip");
2430            let unbf: String = conn.query_row("SELECT tags FROM events WHERE id='unbf'", [], |r| r.get(0)).unwrap();
2431            assert!(unbf.contains("attachments"), "un-backfilled event keeps its tag (no table row)");
2432        }
2433
2434        // The un-backfilled event still renders via the read fallback after the strip.
2435        let chat_int = crate::db::id_cache::get_or_create_chat_id("npub1s").unwrap();
2436        let views = get_message_views(chat_int, 10, 0).await.unwrap();
2437        assert_eq!(views.iter().find(|m| m.id == "unbf").unwrap().attachments.len(), 1, "fallback still renders unbf");
2438    }
2439
2440    // Mark-as-unread anchors from the FULL DB history (a community row often holds only a preview
2441    // message in RAM). The anchor lands strictly before the target's second so the count query's
2442    // strict `>` still counts the newest contact message. Covers the community repro + edge cases.
2443    #[tokio::test]
2444    async fn compute_unread_anchor_covers_the_cases() {
2445        let (_tmp, _guard) = init_test_db();
2446        let mk = |id: &str, secs: u64, mine: bool| Message {
2447            id: id.into(), content: "x".into(), at: secs * 1000, mine,
2448            npub: (!mine).then(|| "npub1sender".to_string()),
2449            ..Default::default()
2450        };
2451        let unread = |chat: &'static str| async move {
2452            unread_counts().await.unwrap().get(chat).copied().unwrap_or(0)
2453        };
2454        let set_lr = |chat: &str, lr: &str| {
2455            let conn = crate::db::get_write_connection_guard_static().unwrap();
2456            conn.execute("UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
2457                rusqlite::params![lr, chat]).unwrap();
2458        };
2459
2460        // (A) The community repro: we spoke long ago, they kept talking. Anchor = second-newest
2461        // contact message → exactly one unread, whatever the RAM cache held.
2462        let a = "npub1anchorA";
2463        save_message(a, &mk("a_mine", 1000, true)).await.unwrap();
2464        for i in 0..8u64 { save_message(a, &mk(&format!("a{i}"), 2000 + i, false)).await.unwrap(); }
2465        assert_eq!(compute_unread_anchor(a).await.unwrap(), UnreadMark::Anchor("a6".into()));
2466        set_lr(a, "a6");
2467        assert_eq!(unread(a).await, 1, "A: newest contact message is the sole unread");
2468
2469        // (B) We spoke last → NoOp (no phantom badge, no snap-back jiggle).
2470        let b = "npub1anchorB";
2471        save_message(b, &mk("b0", 2000, false)).await.unwrap();
2472        save_message(b, &mk("b_mine", 2001, true)).await.unwrap();
2473        assert_eq!(compute_unread_anchor(b).await.unwrap(), UnreadMark::NoOp);
2474
2475        // (C) Same-second tail: the two newest share a second. The anchor must skip to a strictly
2476        // earlier second, so both same-second messages surface instead of snapping back to read.
2477        let c = "npub1anchorC";
2478        save_message(c, &mk("c0", 3000, false)).await.unwrap();
2479        save_message(c, &mk("c1", 3005, false)).await.unwrap();
2480        save_message(c, &mk("c2", 3005, false)).await.unwrap();
2481        assert_eq!(compute_unread_anchor(c).await.unwrap(), UnreadMark::Anchor("c0".into()));
2482        set_lr(c, "c0");
2483        assert_eq!(unread(c).await, 2, "C: same-second tail both count");
2484
2485        // (D) The newest contact message is the chat's first → Clear (never-read) → it still counts.
2486        let d = "npub1anchorD";
2487        save_message(d, &mk("d0", 4000, false)).await.unwrap();
2488        assert_eq!(compute_unread_anchor(d).await.unwrap(), UnreadMark::Clear);
2489        set_lr(d, "");
2490        assert_eq!(unread(d).await, 1, "D: lone contact message surfaces");
2491
2492        // (E) No contact message at all (only our own) → NoOp.
2493        let e = "npub1anchorE";
2494        save_message(e, &mk("e_mine", 5000, true)).await.unwrap();
2495        assert_eq!(compute_unread_anchor(e).await.unwrap(), UnreadMark::NoOp);
2496    }
2497
2498    // Regression: a "read to here" marker that lands on a system event (kind 30078, not a counted
2499    // kind, e.g. the windowed jump-reveal path marking off the raw tail) must still clear unread.
2500    // The anchor keys off the marker row's time whatever its kind, so it can't wedge at 99+.
2501    /// A blank conversation id is refused, never minted into a chat row. Left to
2502    /// `get_or_create_chat_id` it created a chat keyed by "" — and because the
2503    /// identifier is UNIQUE, presence from EVERY channel-less community collapsed
2504    /// into that one unopenable row (82 events across weeks, on a live account).
2505    #[tokio::test]
2506    async fn a_system_event_with_a_blank_conversation_id_is_refused() {
2507        let (_tmp, _guard) = init_test_db();
2508        let before = chat_row_count();
2509        for blank in ["", "   "] {
2510            assert!(
2511                save_system_event_at("ev", blank, SystemEventType::MemberJoined, "npubX", None, 100, None, None)
2512                    .await
2513                    .is_err(),
2514                "a blank conversation id must be refused, not minted"
2515            );
2516        }
2517        assert_eq!(chat_row_count(), before, "and no chat row is created");
2518        // A real id still works.
2519        assert!(
2520            save_system_event_at("ev2", "real-chat", SystemEventType::MemberJoined, "npubX", None, 100, None, None)
2521                .await
2522                .unwrap()
2523        );
2524    }
2525
2526    fn chat_row_count() -> i64 {
2527        let conn = crate::db::get_db_connection_guard_static().unwrap();
2528        conn.query_row("SELECT count(*) FROM chats", [], |r| r.get(0)).unwrap()
2529    }
2530
2531    #[tokio::test]
2532    async fn unread_clears_when_last_read_is_a_system_event() {
2533        let (_tmp, _guard) = init_test_db();
2534        let chat = "npub1sysevtdm";
2535        let mk = |id: &str, secs: u64| Message {
2536            id: id.into(), content: "x".into(), at: secs * 1000, mine: false,
2537            npub: Some("npub1sender".to_string()), ..Default::default()
2538        };
2539        let unread = || async { unread_counts().await.unwrap().get(chat).copied().unwrap_or(0) };
2540
2541        // 5 contact messages, never read → all unread. No own message, so the ONLY viable anchor
2542        // is last_read (this is the case that used to stick at a permanent count).
2543        for i in 0..5u64 {
2544            save_message(chat, &mk(&format!("m{i}"), 1000 + i)).await.unwrap();
2545        }
2546        assert_eq!(unread().await, 5, "never-read backlog");
2547
2548        // A system event is the newest row (a join notification, after every contact message).
2549        save_system_event_at("sysev", chat, SystemEventType::MemberJoined, "npubX", None, 2000, None, None).await.unwrap();
2550
2551        // last_read pinned to that system event (kind 30078) — the reported bad state.
2552        {
2553            let conn = crate::db::get_write_connection_guard_static().unwrap();
2554            conn.execute(
2555                "UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
2556                rusqlite::params!["sysev", chat],
2557            ).unwrap();
2558        }
2559        assert_eq!(unread().await, 0, "read marker on a system event still clears the badge");
2560    }
2561
2562    // Deleting a message adjusts unread correctly: removing an UNREAD message drops the count by
2563    // one, removing the read MARKER retreats it to the prior message (never collapses to 99+), and
2564    // removing the last read message clears the marker without over-counting.
2565    #[tokio::test]
2566    async fn deleting_a_message_adjusts_unread_without_wedging() {
2567        let (_tmp, _guard) = init_test_db();
2568        let chat = "npub1delunread";
2569        let mk = |id: &str, secs: u64| Message {
2570            id: id.into(), content: "x".into(), at: secs * 1000, mine: false,
2571            npub: Some("npub1sender".to_string()), ..Default::default()
2572        };
2573        let unread = || async { unread_counts().await.unwrap().get(chat).copied().unwrap_or(0) };
2574        let set_marker = |id: &str| {
2575            let conn = crate::db::get_write_connection_guard_static().unwrap();
2576            conn.execute("UPDATE chats SET last_read = ?1 WHERE chat_identifier = ?2",
2577                rusqlite::params![id, chat]).unwrap();
2578        };
2579        let marker = || -> String {
2580            let conn = crate::db::get_db_connection_guard_static().unwrap();
2581            conn.query_row("SELECT last_read FROM chats WHERE chat_identifier = ?1",
2582                rusqlite::params![chat], |r| r.get::<_, String>(0)).unwrap()
2583        };
2584
2585        // m0..m5, read up to m1 → m2,m3,m4,m5 unread.
2586        for i in 0..6u64 { save_message(chat, &mk(&format!("m{i}"), 1000 + i)).await.unwrap(); }
2587        set_marker("m1");
2588        assert_eq!(unread().await, 4, "m2..m5 unread");
2589
2590        // Delete an UNREAD mid-block message → badge drops by one, marker untouched.
2591        delete_event("m3").await.unwrap();
2592        assert_eq!(unread().await, 3, "one unread deleted → badge minus one");
2593        assert_eq!(marker(), "m1", "deleting an unread message leaves the marker alone");
2594
2595        // Delete the read MARKER → retreats to the prior surviving message (m0), unread unchanged.
2596        delete_event("m1").await.unwrap();
2597        assert_eq!(marker(), "m0", "marker retreats to the newest survivor before it");
2598        assert_eq!(unread().await, 3, "retreat keeps the count, no collapse to 99+");
2599
2600        // Delete the last surviving read message → marker clears, still exactly the unread block.
2601        delete_event("m0").await.unwrap();
2602        assert_eq!(marker(), "", "no earlier survivor → marker clears");
2603        assert_eq!(unread().await, 3, "cleared marker counts only the true unread survivors");
2604    }
2605
2606    // Edits are event-sourced for BOTH transports: a MESSAGE_EDIT event folds into the target's
2607    // history on reload (latest content + revisions + the edit's own emoji). Community used to
2608    // overwrite the row and lose all of this — this locks in the unified fold.
2609    #[tokio::test]
2610    async fn edit_event_folds_into_history_on_reload() {
2611        let (_tmp, _guard) = init_test_db();
2612        let chat = "channel_edit_fold";
2613        save_message(chat, &Message {
2614            id: "orig1".into(), content: "original".into(), at: 5_000_000,
2615            npub: Some("npub1author".into()), ..Default::default()
2616        }).await.unwrap();
2617
2618        let cid = crate::db::id_cache::get_chat_id_by_identifier(chat).unwrap();
2619        let emoji = vec![crate::types::EmojiTag { shortcode: "wave".into(), url: "u/wave".into() }];
2620        save_edit_event("edit1", "orig1", "edited :wave:", &emoji, cid, None, "npub1author").await.unwrap();
2621
2622        let m = get_message_views(cid, 50, 0).await.unwrap()
2623            .into_iter().find(|m| m.id == "orig1").expect("message reloaded");
2624        assert!(m.edited, "folded edit sets the edited flag");
2625        let h = m.edit_history.as_ref().expect("history reconstructed from the edit event");
2626        assert_eq!(h.len(), 2, "original + one edit");
2627        assert_eq!(h[0].content, "original");
2628        assert_eq!(h[1].content, "edited :wave:");
2629        assert_eq!(m.content, "edited :wave:", "latest revision is the displayed content");
2630        assert_eq!(m.emoji_tags.len(), 1, "the edit's own emoji folds onto the message");
2631        assert_eq!(m.emoji_tags[0].shortcode, "wave");
2632    }
2633
2634    // The bulk-sync persist path: one transaction must land events + attachments + reactions
2635    // with the same shape save_message produces, and slice order must set rowid order (the
2636    // same-timestamp pagination tiebreak).
2637    #[tokio::test]
2638    async fn batched_save_matches_single_save_shape() {
2639        let (_tmp, _guard) = init_test_db();
2640        let chat = "channel_batch1";
2641        let att = crate::types::Attachment {
2642            id: "atthash1".into(), extension: "png".into(), name: "a.png".into(),
2643            url: "https://x/att".into(), downloaded: false, ..Default::default()
2644        };
2645        let reaction = Reaction {
2646            id: "react_b1".into(), reference_id: "b1".into(),
2647            author_id: "npub1reactor".into(), emoji: "👍".into(), emoji_url: None,
2648        };
2649        // Same `at` second across the batch — rowid is the only orderer.
2650        let msgs: Vec<Message> = (0..5u64).map(|i| Message {
2651            id: format!("b{i}"), content: format!("c{i}"), at: 7_000_000,
2652            npub: Some("npub1sender".into()),
2653            attachments: if i == 2 { vec![att.clone()] } else { Vec::new() },
2654            reactions: if i == 1 { vec![reaction.clone()] } else { Vec::new() },
2655            ..Default::default()
2656        }).collect();
2657        let refs: Vec<&Message> = msgs.iter().collect();
2658
2659        let saved = save_messages_batch(chat, &refs).await.unwrap();
2660        assert_eq!(saved, 5, "every message written");
2661
2662        for i in 0..5u64 {
2663            assert!(event_exists(&format!("b{i}")).unwrap(), "b{i} row exists");
2664        }
2665        assert!(event_exists("react_b1").unwrap(), "reaction landed as its own kind-7 row");
2666        let atts = crate::db::attachments::get_attachments_for_event("b2").unwrap();
2667        assert_eq!(atts.len(), 1, "attachment row committed with its event");
2668        assert_eq!(atts[0].id, "atthash1");
2669
2670        // rowid order == slice order despite identical timestamps.
2671        let conn = crate::db::get_db_connection_guard_static().unwrap();
2672        let ids: Vec<String> = conn
2673            .prepare("SELECT id FROM events WHERE id IN ('b0','b1','b2','b3','b4') ORDER BY rowid")
2674            .unwrap()
2675            .query_map([], |r| r.get(0)).unwrap()
2676            .flatten().collect();
2677        assert_eq!(ids, vec!["b0", "b1", "b2", "b3", "b4"], "insert order preserves the rowid tiebreak");
2678    }
2679
2680    /// Register `chat` as a channel of `community` so the ban filter can resolve it.
2681    fn link_channel_to_community(chat: &str, community: &str) {
2682        let conn = crate::db::get_write_connection_guard_static().unwrap();
2683        conn.execute(
2684            "INSERT OR REPLACE INTO community_channels \
2685             (channel_id, community_id, channel_key, epoch, name, created_at) \
2686             VALUES (?1, ?2, x'00', 0, 'general', 0)",
2687            rusqlite::params![chat, community],
2688        )
2689        .unwrap();
2690    }
2691
2692    /// The banlist speaks HEX and `events.npub` speaks bech32 — the store has to
2693    /// bridge them or the filter silently matches nothing in production.
2694    fn ban_pair() -> (String, String) {
2695        let k = nostr_sdk::prelude::Keys::generate();
2696        use nostr_sdk::prelude::ToBech32;
2697        (k.public_key().to_hex(), k.public_key().to_bech32().unwrap())
2698    }
2699
2700    /// A ban hides what the author already posted — not only their next message.
2701    #[tokio::test]
2702    async fn a_banned_authors_messages_are_not_returned() {
2703        let (_tmp, _guard) = init_test_db();
2704        let chat = "channel_ban_hide";
2705        link_channel_to_community(chat, "comm_hide");
2706        let (raider_hex, raider) = ban_pair();
2707        let (_, good) = ban_pair();
2708        for (id, npub, at) in [("keep1", &good, 1_000u64), ("spam1", &raider, 2_000), ("keep2", &good, 3_000)] {
2709            let msg = Message { id: id.into(), content: "hi".into(), at, npub: Some(npub.clone()), ..Default::default() };
2710            save_message(chat, &msg).await.unwrap();
2711        }
2712        crate::db::community::set_community_banlist("comm_hide", &[raider_hex], 1).unwrap();
2713
2714        let chat_id = crate::db::id_cache::get_chat_id_by_identifier(chat).unwrap();
2715        let got = get_events(chat_id, None, 50, 0).await.unwrap();
2716        let ids: Vec<&str> = got.iter().map(|e| e.id.as_str()).collect();
2717        assert!(!ids.contains(&"spam1"), "a banned author's existing message must not be returned: {ids:?}");
2718        assert!(ids.contains(&"keep1") && ids.contains(&"keep2"), "everyone else survives: {ids:?}");
2719    }
2720
2721    /// THE pagination guard. Filtering after `LIMIT` returns a short page, which the
2722    /// caller reads as "end of history" — backscroll stops dead behind a wall of
2723    /// banned events. Excluding before `LIMIT` keeps a full page full.
2724    #[tokio::test]
2725    async fn a_wall_of_banned_events_does_not_shorten_the_page() {
2726        let (_tmp, _guard) = init_test_db();
2727        let chat = "channel_ban_page";
2728        link_channel_to_community(chat, "comm_page");
2729        // 40 banned interleaved with 20 real ones, banned strictly newer so a
2730        // naive newest-first page would be nothing but spam.
2731        let (raider_hex, raider) = ban_pair();
2732        let (_, good) = ban_pair();
2733        for i in 0..20u64 {
2734            let msg = Message { id: format!("real{i}"), content: "hi".into(), at: 1_000 + i, npub: Some(good.clone()), ..Default::default() };
2735            save_message(chat, &msg).await.unwrap();
2736        }
2737        for i in 0..40u64 {
2738            let msg = Message { id: format!("spam{i}"), content: "buy".into(), at: 5_000 + i, npub: Some(raider.clone()), ..Default::default() };
2739            save_message(chat, &msg).await.unwrap();
2740        }
2741        crate::db::community::set_community_banlist("comm_page", &[raider_hex], 1).unwrap();
2742
2743        let chat_id = crate::db::id_cache::get_chat_id_by_identifier(chat).unwrap();
2744        let page = get_events(chat_id, None, 20, 0).await.unwrap();
2745        assert_eq!(page.len(), 20, "a page of 20 must return 20 real messages, not what is left after filtering");
2746        assert!(page.iter().all(|e| e.npub.as_deref() == Some(good.as_str())), "no banned author leaks into the page");
2747    }
2748
2749    /// The kind-filtered path is the one the app actually calls — a page of chat
2750    /// messages. Covering only the kindless branch left it unguarded.
2751    #[tokio::test]
2752    async fn the_kind_filtered_page_excludes_banned_authors_too() {
2753        let (_tmp, _guard) = init_test_db();
2754        let chat = "channel_ban_kinds";
2755        link_channel_to_community(chat, "comm_kinds");
2756        let (raider_hex, raider) = ban_pair();
2757        let (_, good) = ban_pair();
2758        for i in 0..12u64 {
2759            let msg = Message { id: format!("real{i}"), content: "hi".into(), at: 1_000 + i, npub: Some(good.clone()), ..Default::default() };
2760            save_message(chat, &msg).await.unwrap();
2761        }
2762        for i in 0..30u64 {
2763            let msg = Message { id: format!("spam{i}"), content: "buy".into(), at: 5_000 + i, npub: Some(raider.clone()), ..Default::default() };
2764            save_message(chat, &msg).await.unwrap();
2765        }
2766        crate::db::community::set_community_banlist("comm_kinds", &[raider_hex], 1).unwrap();
2767
2768        let chat_id = crate::db::id_cache::get_chat_id_by_identifier(chat).unwrap();
2769        let kinds = [event_kind::PRIVATE_DIRECT_MESSAGE];
2770        let page = get_events(chat_id, Some(&kinds), 12, 0).await.unwrap();
2771        assert_eq!(page.len(), 12, "the kind-filtered page must fill with real messages");
2772        assert!(page.iter().all(|e| e.npub.as_deref() == Some(good.as_str())), "no banned author in a kind-filtered page");
2773    }
2774
2775    /// A ban erases their arrival too. The "has joined" row is a system event
2776    /// carrying the same author, so nothing about a banned account should remain
2777    /// on screen — not the spam, not the trace of them walking in.
2778    #[tokio::test]
2779    async fn a_banned_members_join_event_is_hidden_with_their_messages() {
2780        let (_tmp, _guard) = init_test_db();
2781        let chat = "channel_ban_join";
2782        link_channel_to_community(chat, "comm_join");
2783        let (raider_hex, raider) = ban_pair();
2784
2785        save_system_event_at("joined_evt", chat, SystemEventType::MemberJoined, &raider, None, 4_000, None, None)
2786            .await
2787            .unwrap();
2788        let msg = Message { id: "spam_after_join".into(), content: "buy".into(), at: 5_000, npub: Some(raider.clone()), ..Default::default() };
2789        save_message(chat, &msg).await.unwrap();
2790        let chat_id = crate::db::id_cache::get_chat_id_by_identifier(chat).unwrap();
2791        // Present BEFORE the ban, or the assertions below prove nothing.
2792        assert_eq!(get_events(chat_id, None, 50, 0).await.unwrap().len(), 2, "both rows are on record first");
2793
2794        crate::db::community::set_community_banlist("comm_join", &[raider_hex], 1).unwrap();
2795
2796        let got = get_events(chat_id, None, 50, 0).await.unwrap();
2797        let ids: Vec<&str> = got.iter().map(|e| e.id.as_str()).collect();
2798        assert!(!ids.contains(&"joined_evt"), "the join row goes with them: {ids:?}");
2799        assert!(!ids.contains(&"spam_after_join"), "and so does what they said: {ids:?}");
2800    }
2801
2802    /// A badge that counts a banned author sends the reader hunting for a message
2803    /// the client will never render.
2804    #[tokio::test]
2805    async fn a_banned_authors_messages_do_not_badge_the_channel() {
2806        let (_tmp, _guard) = init_test_db();
2807        let chat = "channel_ban_unread";
2808        link_channel_to_community(chat, "comm_unread");
2809        let (raider_hex, raider) = ban_pair();
2810        let (_, good) = ban_pair();
2811
2812        let real = Message { id: "real_unread".into(), content: "hi".into(), at: 1_000, npub: Some(good.clone()), ..Default::default() };
2813        save_message(chat, &real).await.unwrap();
2814        for i in 0..5u64 {
2815            let spam = Message { id: format!("spam_unread{i}"), content: "buy".into(), at: 2_000 + i, npub: Some(raider.clone()), ..Default::default() };
2816            save_message(chat, &spam).await.unwrap();
2817        }
2818        assert_eq!(unread_count_for_chat(chat).await.unwrap(), 6, "before the ban everything counts");
2819
2820        crate::db::community::set_community_banlist("comm_unread", &[raider_hex], 1).unwrap();
2821
2822        assert_eq!(
2823            unread_count_for_chat(chat).await.unwrap(), 1,
2824            "only the message that still renders is counted"
2825        );
2826        let all = unread_counts().await.unwrap();
2827        assert_eq!(all.get(chat).copied().unwrap_or(0), 1, "and the all-chats sweep agrees");
2828    }
2829
2830    /// Join/leave lines have their OWN query — the chat page never sees them — so
2831    /// filtering the message page alone left a wall of "has joined" from accounts
2832    /// whose every message the client already refused to render.
2833    #[tokio::test]
2834    async fn a_banned_members_join_line_is_dropped_from_the_system_feed() {
2835        let (_tmp, _guard) = init_test_db();
2836        let chat = "channel_ban_sysfeed";
2837        link_channel_to_community(chat, "comm_sysfeed");
2838        let (raider_hex, raider) = ban_pair();
2839        let (_, good) = ban_pair();
2840
2841        for (id, who) in [("join_good", &good), ("join_raider", &raider)] {
2842            save_system_event_at(id, chat, SystemEventType::MemberJoined, who, None, 1_000, None, None)
2843                .await
2844                .unwrap();
2845        }
2846        assert_eq!(get_system_events_for_chat(chat).unwrap().len(), 2, "both arrivals are on record");
2847
2848        crate::db::community::set_community_banlist("comm_sysfeed", &[raider_hex], 1).unwrap();
2849
2850        let feed = get_system_events_for_chat(chat).unwrap();
2851        let ids: Vec<&str> = feed.iter().map(|e| e.id.as_str()).collect();
2852        assert_eq!(ids, vec!["join_good"], "the banned member's arrival goes with their messages");
2853    }
2854
2855    /// A moderator's delete has to outlive the row it removed.
2856    ///
2857    /// Dropping the events row alone only hides the message until the next full
2858    /// sync re-serves the original from a relay: nothing recorded that it was
2859    /// removed, so it re-ingests cleanly and the deletion silently undoes itself.
2860    /// Observed live — an image Sentinel deleted for NSFW reappeared after a
2861    /// cold resync.
2862    #[tokio::test]
2863    async fn a_moderated_delete_survives_the_message_being_re_ingested() {
2864        let (_tmp, _guard) = init_test_db();
2865        let chat = "channel_delete_resync";
2866        let (_, author) = ban_pair();
2867        let msg = Message { id: "moderated_evt".into(), content: "nsfw".into(), at: 1_000, npub: Some(author.clone()), ..Default::default() };
2868        save_message(chat, &msg).await.unwrap();
2869
2870        // What the moderation path does on receiving the delete.
2871        crate::state::note_message_deleted("moderated_evt");
2872        add_message_tombstone("moderated_evt").unwrap();
2873        delete_event("moderated_evt").await.unwrap();
2874
2875        let chat_id = crate::db::id_cache::get_chat_id_by_identifier(chat).unwrap();
2876        assert!(get_events(chat_id, None, 50, 0).await.unwrap().is_empty(), "removed on the spot");
2877
2878        // The relay re-serves the original on the next sync.
2879        assert!(
2880            crate::state::was_message_deleted("moderated_evt"),
2881            "the refusal outlives the row, so the re-ingest is dropped rather than resurrecting it"
2882        );
2883    }
2884
2885    /// Backscroll windows around an anchor, so a ban has to hold there too —
2886    /// otherwise jump-to-message walks straight back into the spam the chat page
2887    /// refuses to show.
2888    #[tokio::test]
2889    async fn the_backscroll_window_excludes_banned_authors() {
2890        let (_tmp, _guard) = init_test_db();
2891        let chat = "channel_ban_window";
2892        link_channel_to_community(chat, "comm_window");
2893        let (raider_hex, raider) = ban_pair();
2894        let (_, good) = ban_pair();
2895
2896        for i in 0..10u64 {
2897            let m = Message { id: format!("w_real{i}"), content: "hi".into(), at: 1_000 + i, npub: Some(good.clone()), ..Default::default() };
2898            save_message(chat, &m).await.unwrap();
2899            let s = Message { id: format!("w_spam{i}"), content: "buy".into(), at: 1_500 + i, npub: Some(raider.clone()), ..Default::default() };
2900            save_message(chat, &s).await.unwrap();
2901        }
2902        crate::db::community::set_community_banlist("comm_window", &[raider_hex], 1).unwrap();
2903
2904        let chat_id = crate::db::id_cache::get_chat_id_by_identifier(chat).unwrap();
2905        let win = get_messages_around(chat_id, "w_real5", 20, 20).await.unwrap();
2906        assert!(!win.is_empty(), "the window still returns the real messages");
2907        assert!(
2908            win.iter().all(|m| m.npub.as_deref() == Some(good.as_str())),
2909            "no banned author reachable by jumping to a message near them",
2910        );
2911    }
2912
2913    /// A DM has no community, so the predicate must not exclude anything.
2914    #[tokio::test]
2915    async fn a_dm_is_unaffected_by_the_ban_filter() {
2916        let (_tmp, _guard) = init_test_db();
2917        let chat = "npub1peer_dm";
2918        let msg = Message { id: "dm1".into(), content: "hi".into(), at: 1_000, npub: Some("npub1peer".into()), ..Default::default() };
2919        save_message(chat, &msg).await.unwrap();
2920        let chat_id = crate::db::id_cache::get_chat_id_by_identifier(chat).unwrap();
2921        assert_eq!(get_events(chat_id, None, 50, 0).await.unwrap().len(), 1, "a DM resolves no community and filters nothing");
2922    }
2923
2924    // A batched re-save must keep save_message's upsert semantics: wrapper_event_id is
2925    // COALESCE-preserved, and a duplicate reaction in the batch never doubles its row.
2926    #[tokio::test]
2927    async fn batched_resave_preserves_wrapper_and_dedups_reactions() {
2928        let (_tmp, _guard) = init_test_db();
2929        let chat = "channel_batch2";
2930        let reaction = Reaction {
2931            id: "react_rs".into(), reference_id: "rs1".into(),
2932            author_id: "npub1reactor".into(), emoji: "🔥".into(), emoji_url: None,
2933        };
2934        let mut msg = Message {
2935            id: "rs1".into(), content: "hello".into(), at: 8_000_000,
2936            npub: Some("npub1sender".into()),
2937            wrapper_event_id: Some("wrap_original".into()),
2938            reactions: vec![reaction],
2939            ..Default::default()
2940        };
2941        save_message(chat, &msg).await.unwrap();
2942
2943        // Re-delivery re-save without a wrapper id, reaction still attached.
2944        msg.wrapper_event_id = None;
2945        let saved = save_messages_batch(chat, &[&msg]).await.unwrap();
2946        assert_eq!(saved, 1);
2947
2948        let conn = crate::db::get_db_connection_guard_static().unwrap();
2949        let wrapper: Option<String> = conn.query_row(
2950            "SELECT wrapper_event_id FROM events WHERE id = 'rs1'", [], |r| r.get(0),
2951        ).unwrap();
2952        assert_eq!(wrapper.as_deref(), Some("wrap_original"), "COALESCE keeps the known wrapper");
2953        let reaction_rows: i64 = conn.query_row(
2954            "SELECT COUNT(*) FROM events WHERE id = 'react_rs'", [], |r| r.get(0),
2955        ).unwrap();
2956        assert_eq!(reaction_rows, 1, "reaction row not duplicated by the re-save");
2957    }
2958
2959    // The DM stream's multi-chat flush: one call, one transaction, rows land under their own
2960    // chats with their gift-wrap ledger entries; a flush against a stale session drops the
2961    // buffer AND leaves the wrappers unledgered (that's what makes the drop recoverable).
2962    #[tokio::test]
2963    async fn batching_persist_flushes_multi_chat_and_drains_into_the_account_that_filled_it() {
2964        let (_tmp, _guard) = init_test_db();
2965        let handler = crate::event_handler::NoOpEventHandler;
2966        let batcher = crate::event_handler::BatchingPersist::new(&handler);
2967
2968        let mk = |id: &str, npub: &str| Message {
2969            id: id.into(), content: "x".into(), at: 9_000_000,
2970            npub: Some(npub.into()), ..Default::default()
2971        };
2972        // Mirror the commit path: buffering only ever happens AFTER the STATE add (the
2973        // flush drops anything not STATE-resident as deletion protection).
2974        let seed = |chat: &str, m: &Message| {
2975            let m = m.clone();
2976            let chat = chat.to_string();
2977            async move {
2978                let mut st = crate::state::STATE.lock().await;
2979                st.add_message_to_participant(&chat, &m);
2980            }
2981        };
2982        let wrap_a1 = ([0xA1u8; 32], 111u64);
2983        let wrap_b1 = ([0xB1u8; 32], 222u64);
2984        let a1 = mk("bp_a1", "npub1chata");
2985        let b1 = mk("bp_b1", "npub1chatb");
2986        let a2 = mk("bp_a2", "npub1chata");
2987        seed("npub1chata", &a1).await;
2988        seed("npub1chatb", &b1).await;
2989        seed("npub1chata", &a2).await;
2990
2991        use crate::event_handler::InboundEventHandler;
2992        assert!(batcher.buffer_persist("npub1chata", &a1, Some(wrap_a1)), "batcher owns the persist");
2993        assert!(batcher.buffer_persist("npub1chatb", &b1, Some(wrap_b1)));
2994        assert!(batcher.buffer_persist("npub1chata", &a2, None));
2995        assert_eq!(batcher.buffered(), 3);
2996
2997        let ledgered = |bytes: [u8; 32]| {
2998            let id = nostr_sdk::prelude::EventId::from_byte_array(bytes);
2999            crate::db::wrappers::load_negentropy_items().unwrap().iter().any(|(e, _)| *e == id)
3000        };
3001        assert!(!ledgered(wrap_a1.0), "wrapper unledgered while its message sits buffered");
3002
3003        assert_eq!(batcher.flush().await, 3, "all buffered messages written");
3004        assert_eq!(batcher.buffered(), 0);
3005        assert!(event_exists("bp_a1").unwrap() && event_exists("bp_b1").unwrap() && event_exists("bp_a2").unwrap());
3006        assert!(ledgered(wrap_a1.0) && ledgered(wrap_b1.0), "wrappers ledgered with the flush");
3007        let a = crate::db::id_cache::get_chat_id_by_identifier("npub1chata").unwrap();
3008        let b = crate::db::id_cache::get_chat_id_by_identifier("npub1chatb").unwrap();
3009        assert_ne!(a, b, "rows grouped under their own chats");
3010
3011        // A swap between buffering and flushing. The batcher holds the account
3012        // that filled it, so the drain lands there rather than in whoever is
3013        // live by the time it runs — the buffer is one account's inbox.
3014        let filled_under = crate::db::current_session();
3015        let late = mk("bp_late", "npub1chata");
3016        seed("npub1chata", &late).await;
3017        let wrap_late = ([0x5Eu8; 32], 333u64);
3018        batcher.buffer_persist("npub1chata", &late, Some(wrap_late));
3019
3020        let next = make_test_npub(TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
3021        std::fs::create_dir_all(crate::db::shared_test_data_dir().join(&next)).unwrap();
3022        crate::db::set_current_account(next.clone()).unwrap();
3023        crate::db::init_database(&next).unwrap();
3024
3025        assert_eq!(batcher.flush().await, 1, "the buffer still drains");
3026        assert!(
3027            crate::db::with_session(filled_under, async {
3028                event_exists("bp_late").unwrap() && ledgered(wrap_late.0)
3029            })
3030            .await,
3031            "written to the account that received it, and its wrapper ledgered there"
3032        );
3033        assert!(!event_exists("bp_late").unwrap(), "the account swapped in never sees it");
3034        assert_eq!(batcher.buffered(), 0, "and the buffer is empty either way");
3035    }
3036
3037    // A deletion landing while its target sits buffered must not resurrect the message:
3038    // the same-task path purges via on_message_deleted, and the cross-task path (live
3039    // subscription, different handler) is caught by the flush's deletion-tombstone filter.
3040    // The filter keys on the POSITIVE tombstone, never STATE absence — an LRU-evicted (but
3041    // not deleted) message MUST still persist and ledger, or archive sync could silently
3042    // drop the exact history it exists to persist.
3043    #[tokio::test]
3044    async fn a_delete_tombstone_survives_a_restart() {
3045        // NIP-09 is best-effort: a relay that ignored it re-serves the wrap days
3046        // later. The events row is gone by then and the in-session tombstone set
3047        // died with the process — only the durable row lets ingest keep refusing.
3048        let (_tmp, _guard) = init_test_db();
3049        let msg = Message {
3050            id: "resurrect-me".into(), content: "note".into(), at: 1_000_000,
3051            mine: true, ..Default::default()
3052        };
3053        save_message("npub1notes", &msg).await.unwrap();
3054        add_message_tombstone("resurrect-me").unwrap();
3055        delete_event("resurrect-me").await.unwrap();
3056
3057        // Simulated restart/swap: the in-session set dies...
3058        crate::db::close_database();
3059        assert!(!crate::state::was_message_deleted("resurrect-me"), "session set cleared");
3060        // ...and account init re-seeds the refusal from the durable rows.
3061        crate::state::seed_message_tombstones(load_message_tombstones().unwrap());
3062        assert!(crate::state::was_message_deleted("resurrect-me"), "the durable tombstone re-seeds");
3063    }
3064
3065    #[tokio::test]
3066    async fn buffered_message_deleted_before_flush_never_persists() {
3067        let (_tmp, _guard) = init_test_db();
3068        let handler = crate::event_handler::NoOpEventHandler;
3069        let batcher = crate::event_handler::BatchingPersist::new(&handler);
3070        use crate::event_handler::InboundEventHandler;
3071
3072        let chat = "npub1delchat";
3073        let mk = |id: &str| Message {
3074            id: id.into(), content: "x".into(), at: 9_500_000,
3075            npub: Some(chat.into()), ..Default::default()
3076        };
3077        let ledgered = |bytes: [u8; 32]| {
3078            let id = nostr_sdk::prelude::EventId::from_byte_array(bytes);
3079            crate::db::wrappers::load_negentropy_items().unwrap().iter().any(|(e, _)| *e == id)
3080        };
3081
3082        // Same-task deletion (sync stream): commit_deletion fires on_message_deleted on
3083        // the batching handler → the buffered entry purges immediately.
3084        let m1 = mk("del_sametask");
3085        {
3086            let mut st = crate::state::STATE.lock().await;
3087            st.add_message_to_participant(chat, &m1);
3088        }
3089        batcher.buffer_persist(chat, &m1, Some(([0xD1u8; 32], 444)));
3090        {
3091            let mut st = crate::state::STATE.lock().await;
3092            st.remove_message("del_sametask");
3093        }
3094        batcher.on_message_deleted(chat, "del_sametask");
3095        assert_eq!(batcher.buffered(), 0, "deletion purges the buffered target");
3096
3097        // Cross-task deletion (live subscription, plain handler): no purge call — the
3098        // deletion tombstone (recorded by commit_deletion) drops it at flush time.
3099        let m2 = mk("del_crosstask");
3100        {
3101            let mut st = crate::state::STATE.lock().await;
3102            st.add_message_to_participant(chat, &m2);
3103        }
3104        batcher.buffer_persist(chat, &m2, Some(([0xD2u8; 32], 555)));
3105        crate::state::note_message_deleted("del_crosstask");
3106
3107        // LRU eviction is NOT deletion: gone from STATE, no tombstone → must persist.
3108        let m3 = mk("evicted_ok");
3109        let wrap_evicted = ([0xE0u8; 32], 666u64);
3110        {
3111            let mut st = crate::state::STATE.lock().await;
3112            st.add_message_to_participant(chat, &m3);
3113            st.remove_message("evicted_ok");
3114        }
3115        batcher.buffer_persist(chat, &m3, Some(wrap_evicted));
3116
3117        assert_eq!(batcher.flush().await, 1, "tombstoned target dropped, evicted message written");
3118        assert!(!event_exists("del_sametask").unwrap(), "purged message never persisted");
3119        assert!(!event_exists("del_crosstask").unwrap(), "tombstoned message never persisted");
3120        assert!(event_exists("evicted_ok").unwrap(), "evicted-but-not-deleted message persisted");
3121        assert!(ledgered(wrap_evicted.0), "evicted message's wrapper ledgered with it");
3122    }
3123}
3124
3125/// The stored context a pin proof needs to recover a message's wrap: its
3126/// `wrapper_event_id` and the rumor's stored tags (for the epoch binding).
3127pub fn get_event_wrap_context(event_id: &str) -> Result<Option<(Option<String>, Vec<Vec<String>>)>, String> {
3128    let conn = super::get_db_connection_guard_static()?;
3129    let row: Option<(Option<String>, String)> = conn
3130        .query_row(
3131            "SELECT wrapper_event_id, tags FROM events WHERE id = ?1",
3132            rusqlite::params![event_id],
3133            |r| Ok((r.get(0)?, r.get(1)?)),
3134        )
3135        .optional()
3136        .map_err(|e| format!("get wrap context: {e}"))?;
3137    Ok(row.map(|(wrap, tags_json)| {
3138        let tags: Vec<Vec<String>> = serde_json::from_str(&tags_json).unwrap_or_default();
3139        (wrap, tags)
3140    }))
3141}