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