Skip to main content

vector_core/
event_handler.rs

1//! Event handler — gift wrap receive, unwrap, process, commit pipeline.
2//!
3//! Two-phase architecture:
4//! - **Phase 1** (`prepare_event`): Parallel-safe — dedup, unwrap, process_rumor
5//! - **Phase 2** (`commit_prepared_event`): Sequential — save DB, update STATE, emit
6//!
7//! Platform-specific behavior (notifications) handled by `InboundEventHandler` trait.
8
9use nostr_sdk::prelude::*;
10
11use crate::rumor::{RumorProcessingResult, RumorEvent, RumorContext, ConversationType, process_rumor};
12use crate::types::Message;
13use crate::state::WRAPPER_ID_CACHE;
14
15/// Platform-specific callbacks for inbound event processing.
16///
17/// Same pattern as SendCallback/ProfileSyncHandler — trait with default no-ops.
18/// Platforms implement only the hooks they need.
19pub trait InboundEventHandler: Send + Sync {
20    /// A DM text message was received and committed to STATE + DB.
21    fn on_dm_received(&self, _chat_id: &str, _msg: &Message, _is_new: bool) {}
22
23    /// A DM file attachment was received and committed to STATE + DB.
24    fn on_file_received(&self, _chat_id: &str, _msg: &Message, _is_new: bool) {}
25
26    /// A reaction was received and applied to a message.
27    fn on_reaction_received(&self, _chat_id: &str, _msg: &Message) {}
28
29    /// A previously-stored message was deleted by its sender (Layer 2
30    /// cooperative hide via NIP-09 over NIP-17). Frontend drops the row.
31    fn on_message_deleted(&self, _chat_id: &str, _message_id: &str) {}
32
33    /// A Community invite was received over a gift wrap and the local user was
34    /// joined (member-view Community persisted). Platform refreshes the Community
35    /// subscription so messages start flowing, and surfaces the new Community in the UI.
36    fn on_community_invite(&self, _community_id: &str) {}
37
38    // --- Community realtime (Concord channel events; `chat_id` is the channel id hex) ---
39
40    /// A new Community channel message was received, ingested into STATE, and persisted.
41    fn on_community_message(&self, _chat_id: &str, _msg: &Message, _is_new: bool) {}
42
43    /// A reaction or edit was applied to an existing Community message. `target_id` is the
44    /// affected message; `msg` is the live-updated view.
45    fn on_community_update(&self, _chat_id: &str, _target_id: &str, _msg: &Message) {}
46
47    /// A Community message was removed (cooperative delete / moderation tombstone).
48    fn on_community_removed(&self, _chat_id: &str, _target_id: &str) {}
49
50    /// A join/leave presence announcement. `created_at` is the authenticated inner timestamp;
51    /// `invited_by`/`invited_label` carry invite attribution when present.
52    #[allow(clippy::too_many_arguments)]
53    fn on_community_presence(
54        &self,
55        _chat_id: &str,
56        _npub: &str,
57        _joined: bool,
58        _event_id: &str,
59        _created_at: u64,
60        _invited_by: Option<&str>,
61        _invited_label: Option<&str>,
62    ) {}
63
64    /// A Community typing indicator (ephemeral). `until` is the unix-secs the typer stops being active.
65    fn on_community_typing(&self, _chat_id: &str, _npub: &str, _until: u64) {}
66
67    /// A WebXDC realtime peer signal. `node_addr` = `Some` advertises an Iroh node, `None` = peer-left.
68    #[allow(clippy::too_many_arguments)]
69    fn on_community_webxdc(
70        &self,
71        _chat_id: &str,
72        _npub: &str,
73        _topic_id: &str,
74        _node_addr: Option<&str>,
75        _event_id: &str,
76        _created_at: u64,
77    ) {}
78
79    /// The local user was removed from a Community (kick / ban / a leave authored on another device).
80    /// Local data is torn down (epoch keys retained); the platform surfaces it + refreshes subs.
81    fn on_community_self_removed(&self, _community_id: &str) {}
82
83    /// A Community's control plane was refreshed in realtime (banlist/roles/metadata/mode change,
84    /// or a re-founding followed). The platform re-reads display state.
85    fn on_community_refreshed(&self, _community_id: &str) {}
86
87    /// A Community was DISSOLVED by its owner (CORD-02 §9): sealed read-only, held keys still open
88    /// history but nothing new is honored. The platform surfaces the grave.
89    fn on_community_dissolved(&self, _community_id: &str) {}
90
91    /// Bulk-sync persist intercept: return `true` to take ownership of persisting a committed
92    /// DM/file message — the commit then SKIPS its per-message save AND its wrapper-ledger
93    /// write (`wrapper` = the gift-wrap's `(id_bytes, created_at)`; the owner MUST commit it
94    /// in the same transaction as the message row, or the message loses crash/drop
95    /// recoverability). Streaming sync loops (see [`BatchingPersist`]) buffer here and drain
96    /// many messages into one transaction. Default `false` keeps the per-message save.
97    fn buffer_persist(&self, _chat_id: &str, _msg: &Message, _wrapper: Option<([u8; 32], u64)>) -> bool { false }
98}
99
100/// No-op handler for CLI/tests.
101pub struct NoOpEventHandler;
102impl InboundEventHandler for NoOpEventHandler {}
103
104/// One deferred DM persist: the message, its chat, and its gift-wrap ledger entry — the
105/// ledger row commits in the same flush transaction as the message row (see
106/// `save_messages_batch_multi`), so a lost batch leaves the wrapper unledgered.
107struct BufferedDm {
108    chat_id: String,
109    msg: Message,
110    wrapper: Option<([u8; 32], u64)>,
111}
112
113/// Wraps any handler for a bulk-sync drain loop: every callback delegates to the inner
114/// handler, but committed messages BUFFER here instead of saving one transaction each —
115/// the loop calls [`BatchingPersist::flush`] periodically and at stream end to land them
116/// in batched transactions (`save_messages_batch_multi`).
117///
118/// Deferral is recoverable because the wrapper ledger (the negentropy fingerprint set)
119/// rides the flush transaction: a message lost to a crash, a stale-session drop, or a
120/// failed flush leaves its wrapper unledgered, so the next reconciliation re-delivers it.
121pub struct BatchingPersist<'a> {
122    inner: &'a dyn InboundEventHandler,
123    buf: std::sync::Mutex<Vec<BufferedDm>>,
124}
125
126impl<'a> BatchingPersist<'a> {
127    pub fn new(inner: &'a dyn InboundEventHandler) -> Self {
128        Self { inner, buf: std::sync::Mutex::new(Vec::new()) }
129    }
130
131    /// How many messages are waiting — the loop's flush-threshold probe.
132    pub fn buffered(&self) -> usize {
133        self.buf.lock().map(|b| b.len()).unwrap_or(0)
134    }
135
136    /// Drain the buffer into batched transactions (grouped by chat, arrival order kept).
137    /// On a stale session the drained messages are DROPPED, never written into the next
138    /// account's DB — their wrappers stay unledgered, so negentropy re-delivers them when
139    /// the original account returns.
140    pub async fn flush(&self, session: &crate::state::SessionGuard) -> usize {
141        self.try_flush(session).await.unwrap_or(0)
142    }
143
144    /// [`Self::flush`], but a persist failure is distinguishable from "nothing
145    /// to write" — callers that gate follow-on effects on the ledger actually
146    /// holding the batch (reconcile-cursor births) need the difference: an
147    /// advance over an unledgered batch skips those events forever.
148    pub async fn try_flush(&self, session: &crate::state::SessionGuard) -> Result<usize, String> {
149        let mut drained: Vec<BufferedDm> = match self.buf.lock() {
150            Ok(mut b) => b.drain(..).collect(),
151            Err(_) => return Ok(0),
152        };
153        if drained.is_empty() || !session.is_valid() {
154            return Ok(0);
155        }
156        // A deletion may have landed (live subscription or this stream) while an entry sat
157        // buffered: its delete_event no-ops on the not-yet-written row, so persisting the
158        // buffered copy would resurrect a deleted message. Keyed on the POSITIVE deletion
159        // tombstone, never STATE absence — the LRU evicts old messages from STATE, and
160        // archive-synced history is exactly that tail (an evicted message must still
161        // persist). A dropped entry's wrapper stays unledgered and re-delivers next sync,
162        // where the DB dedup sees the (still-deleted) state cleanly.
163        drained.retain(|e| !crate::state::was_message_deleted(&e.msg.id));
164        if drained.is_empty() {
165            return Ok(0);
166        }
167        // Group by chat preserving first-seen chat order + per-chat arrival order.
168        let mut groups: Vec<(String, Vec<(&Message, Option<([u8; 32], u64)>)>)> = Vec::new();
169        for e in &drained {
170            match groups.iter_mut().find(|(c, _)| c == &e.chat_id) {
171                Some((_, v)) => v.push((&e.msg, e.wrapper)),
172                None => groups.push((e.chat_id.clone(), vec![(&e.msg, e.wrapper)])),
173            }
174        }
175        match crate::db::events::save_messages_batch_multi(&groups, Some(session)).await {
176            Ok(n) => Ok(n),
177            Err(e) => {
178                crate::log_warn!("[Sync] batched persist failed ({} msgs): {}", drained.len(), e);
179                Err(e)
180            }
181        }
182    }
183}
184
185impl InboundEventHandler for BatchingPersist<'_> {
186    fn buffer_persist(&self, chat_id: &str, msg: &Message, wrapper: Option<([u8; 32], u64)>) -> bool {
187        match self.buf.lock() {
188            Ok(mut b) => {
189                b.push(BufferedDm { chat_id: chat_id.to_string(), msg: msg.clone(), wrapper });
190                true
191            }
192            // Poisoned lock: fall back to the commit's own per-message save.
193            Err(_) => false,
194        }
195    }
196
197    fn on_dm_received(&self, chat_id: &str, msg: &Message, is_new: bool) {
198        self.inner.on_dm_received(chat_id, msg, is_new)
199    }
200    fn on_file_received(&self, chat_id: &str, msg: &Message, is_new: bool) {
201        self.inner.on_file_received(chat_id, msg, is_new)
202    }
203    fn on_reaction_received(&self, chat_id: &str, msg: &Message) {
204        self.inner.on_reaction_received(chat_id, msg)
205    }
206    fn on_message_deleted(&self, chat_id: &str, message_id: &str) {
207        // The deletion's delete_event no-ops when the target is still buffered here —
208        // purge it (unledgered wrapper → re-delivers → dedups against the deleted state)
209        // so the flush can't resurrect a deleted message.
210        if let Ok(mut b) = self.buf.lock() {
211            b.retain(|e| e.msg.id != message_id);
212        }
213        self.inner.on_message_deleted(chat_id, message_id)
214    }
215    fn on_community_invite(&self, community_id: &str) {
216        self.inner.on_community_invite(community_id)
217    }
218    fn on_community_message(&self, chat_id: &str, msg: &Message, is_new: bool) {
219        self.inner.on_community_message(chat_id, msg, is_new)
220    }
221    fn on_community_update(&self, chat_id: &str, target_id: &str, msg: &Message) {
222        self.inner.on_community_update(chat_id, target_id, msg)
223    }
224    fn on_community_removed(&self, chat_id: &str, target_id: &str) {
225        self.inner.on_community_removed(chat_id, target_id)
226    }
227    fn on_community_presence(
228        &self,
229        chat_id: &str,
230        npub: &str,
231        joined: bool,
232        event_id: &str,
233        created_at: u64,
234        invited_by: Option<&str>,
235        invited_label: Option<&str>,
236    ) {
237        self.inner.on_community_presence(chat_id, npub, joined, event_id, created_at, invited_by, invited_label)
238    }
239    fn on_community_typing(&self, chat_id: &str, npub: &str, until: u64) {
240        self.inner.on_community_typing(chat_id, npub, until)
241    }
242    fn on_community_webxdc(
243        &self,
244        chat_id: &str,
245        npub: &str,
246        topic_id: &str,
247        node_addr: Option<&str>,
248        event_id: &str,
249        created_at: u64,
250    ) {
251        self.inner.on_community_webxdc(chat_id, npub, topic_id, node_addr, event_id, created_at)
252    }
253    fn on_community_self_removed(&self, community_id: &str) {
254        self.inner.on_community_self_removed(community_id)
255    }
256    fn on_community_refreshed(&self, community_id: &str) {
257        self.inner.on_community_refreshed(community_id)
258    }
259    fn on_community_dissolved(&self, community_id: &str) {
260        self.inner.on_community_dissolved(community_id)
261    }
262}
263
264/// Result of Phase 1 (prepare_event) — everything needed for sequential commit.
265pub enum PreparedEvent {
266    /// Fully processed DM rumor — ready for state commit.
267    Processed {
268        result: RumorProcessingResult,
269        contact: String,
270        sender: PublicKey,
271        is_mine: bool,
272        wrapper_event_id: String,
273        wrapper_event_id_bytes: [u8; 32],
274        wrapper_created_at: u64,
275        /// Time spent on ECDH + ChaCha20Poly1305 decryption (nanoseconds)
276        unwrap_ns: u64,
277        /// Time spent on rumor parsing (nanoseconds)
278        parse_ns: u64,
279    },
280    /// Community invite bundle (kind 3304) — parked for explicit user consent.
281    CommunityInvite {
282        invite: crate::community::invite::CommunityInvite,
283        /// Inviter's npub (bech32) — shown in the pending-invite UI.
284        inviter: String,
285        is_mine: bool,
286        wrapper_event_id_bytes: [u8; 32],
287        wrapper_created_at: u64,
288        /// Inner rumor `created_at` (seconds) — the real send time. Unlike the outer
289        /// wrapper, which NIP-59 backdates up to 2 days, this is honest; the tombstone
290        /// supersession test needs it so a re-invite isn't misread as older than a decline.
291        rumor_created_at: u64,
292        /// Sender-declared NIP-40 expiry (unix secs); 0 = none, so permanent.
293        expires_at: u64,
294    },
295    /// Concord v2 Direct Invite (inner kind 3313) — parked for explicit consent.
296    /// Carries the raw bundle JSON (parked verbatim; the accept path re-parses it).
297    CommunityInviteV2 {
298        bundle_json: String,
299        community_id: String,
300        /// Inviter's npub (hex) — the proven seal signer.
301        inviter: String,
302        is_mine: bool,
303        wrapper_event_id_bytes: [u8; 32],
304        wrapper_created_at: u64,
305        /// Inner rumor `created_at` (seconds) — the real send time (see the v1 variant).
306        rumor_created_at: u64,
307        /// Sender-declared NIP-40 expiry (unix secs); 0 = none, so permanent.
308        expires_at: u64,
309    },
310    /// Duplicate event — just persist wrapper for negentropy.
311    DedupSkip {
312        wrapper_id_bytes: [u8; 32],
313        wrapper_created_at: u64,
314    },
315    /// Error during unwrap/processing — persist wrapper for negentropy.
316    ErrorSkip {
317        wrapper_id_bytes: [u8; 32],
318        wrapper_created_at: u64,
319    },
320}
321
322/// Phase 1: Prepare an event for commit (parallel-safe, no state mutation).
323///
324/// Performs dedup check, gift wrap decryption, and rumor parsing.
325/// Safe to call from multiple tokio tasks concurrently.
326pub async fn prepare_event(
327    event: Event,
328    _client: &Client,
329    my_public_key: PublicKey,
330) -> PreparedEvent {
331    let wrapper_created_at = event.created_at.as_secs();
332    let wrapper_event_id_bytes: [u8; 32] = event.id.to_bytes();
333    let wrapper_event_id = event.id.to_hex();
334
335    // Dedup: in-memory cache first, then DB fallback
336    {
337        let cache = WRAPPER_ID_CACHE.lock().await;
338        if cache.contains(&wrapper_event_id_bytes) {
339            return PreparedEvent::DedupSkip { wrapper_id_bytes: wrapper_event_id_bytes, wrapper_created_at };
340        }
341    }
342
343    if let Ok(true) = crate::db::events::wrapper_event_exists(&wrapper_event_id) {
344        return PreparedEvent::DedupSkip { wrapper_id_bytes: wrapper_event_id_bytes, wrapper_created_at };
345    }
346
347    // Unwrap gift wrap (CPU-bound ECDH + ChaCha20Poly1305)
348    let unwrap_start = std::time::Instant::now();
349    let signer = match crate::signer::active_signer() {
350        Ok(s) => s,
351        Err(_) => return PreparedEvent::ErrorSkip {
352            wrapper_id_bytes: wrapper_event_id_bytes, wrapper_created_at,
353        },
354    };
355    let (rumor, sender) = match UnwrappedGift::from_gift_wrap_async(&signer, &event).await {
356        Ok(UnwrappedGift { rumor, sender }) => (rumor, sender),
357        Err(_) => return PreparedEvent::ErrorSkip {
358            wrapper_id_bytes: wrapper_event_id_bytes, wrapper_created_at,
359        },
360    };
361
362    let unwrap_ns = unwrap_start.elapsed().as_nanos() as u64;
363
364    // Inner rumor send time (seconds). The outer wrapper's `created_at` is NIP-59
365    // backdated up to 2 days, so it can't order an invite against a decline tombstone.
366    let rumor_created_at = rumor.created_at.as_secs();
367
368    let is_mine = sender == my_public_key;
369    let contact = if is_mine {
370        rumor.tags.public_keys().next()
371            .and_then(|pk| pk.to_bech32().ok())
372            .unwrap_or_else(|| sender.to_bech32().unwrap_or_default())
373    } else {
374        sender.to_bech32().unwrap_or_default()
375    };
376
377    // Skip NIP-17 group messages (multiple p-tags) — Vector DMs are 1:1
378    if rumor.tags.public_keys().count() > 1 {
379        return PreparedEvent::ErrorSkip {
380            wrapper_id_bytes: wrapper_event_id_bytes, wrapper_created_at,
381        };
382    }
383
384    // Community invite (carrier) — a join, not a chat message. Recognized before
385    // process_rumor so it never lands as an UnknownEvent in the DM thread.
386    if rumor.kind == Kind::Custom(crate::stored_event::event_kind::COMMUNITY_INVITE_BUNDLE) {
387        return match crate::community::invite::parse_invite_rumor(rumor.kind, &rumor.content) {
388            Some(invite) => PreparedEvent::CommunityInvite {
389                invite, inviter: contact.clone(), is_mine, wrapper_event_id_bytes, wrapper_created_at, rumor_created_at,
390                expires_at: crate::community::invite::expiration_secs(&rumor.tags).unwrap_or(0),
391            },
392            None => PreparedEvent::ErrorSkip { wrapper_id_bytes: wrapper_event_id_bytes, wrapper_created_at },
393        };
394    }
395
396    // Concord v2 Direct Invite (inner kind 3313) — the v2 join carrier. `from_bundle_json`
397    // validates the owner commitment + bounds; we park the canonical re-serialized bundle.
398    if rumor.kind == Kind::Custom(crate::community::v2::kind::DIRECT_INVITE) {
399        return match crate::community::v2::invite::CommunityInvite::from_bundle_json(&rumor.content)
400            .ok()
401            .and_then(|b| serde_json::to_string(&b).ok().map(|j| (b.community_id, j)))
402        {
403            Some((community_id, bundle_json)) => PreparedEvent::CommunityInviteV2 {
404                community_id,
405                bundle_json,
406                inviter: contact.clone(), // the seal signer's npub (bech32), like the v1 arm
407                is_mine,
408                wrapper_event_id_bytes,
409                wrapper_created_at,
410                rumor_created_at,
411                expires_at: crate::community::invite::expiration_secs(&rumor.tags).unwrap_or(0),
412            },
413            None => PreparedEvent::ErrorSkip { wrapper_id_bytes: wrapper_event_id_bytes, wrapper_created_at },
414        };
415    }
416
417    // Build RumorEvent for processing
418    let Some(rumor_id) = rumor.id else {
419        return PreparedEvent::ErrorSkip {
420            wrapper_id_bytes: wrapper_event_id_bytes, wrapper_created_at,
421        };
422    };
423
424    let rumor_event = RumorEvent {
425        id: rumor_id,
426        kind: rumor.kind,
427        content: rumor.content,
428        tags: rumor.tags,
429        created_at: rumor.created_at,
430        pubkey: rumor.pubkey,
431    };
432    let rumor_context = RumorContext {
433        sender,
434        is_mine,
435        conversation_id: contact.clone(),
436        conversation_type: ConversationType::DirectMessage,
437    };
438
439    let parse_start = std::time::Instant::now();
440    let download_dir = crate::db::get_download_dir();
441    match process_rumor(rumor_event, rumor_context, &download_dir) {
442        Ok(result) => {
443            let parse_ns = parse_start.elapsed().as_nanos() as u64;
444            PreparedEvent::Processed {
445                result, contact, sender, is_mine,
446                wrapper_event_id, wrapper_event_id_bytes, wrapper_created_at,
447                unwrap_ns, parse_ns,
448            }
449        }
450        Err(e) => {
451            log_warn!("[EventHandler] Failed to process rumor: {}", e);
452            PreparedEvent::ErrorSkip {
453                wrapper_id_bytes: wrapper_event_id_bytes, wrapper_created_at,
454            }
455        }
456    }
457}
458
459// ============================================================================
460// Phase 2: Commit — sequential state mutation, DB save, emit
461// ============================================================================
462
463/// Phase 2: commit a prepared event (sequential — not parallel-safe).
464/// Saves to DB, updates STATE, emits to frontend, calls handler hooks.
465/// Returns true if a new displayable message was committed.
466///
467/// Session-safety: captures the generation at the first line. If a swap
468/// occurred between `prepare_event()` and here (e.g. long-running
469/// negentropy fetch queued events for commit), bail before any STATE /
470/// DB write. Centralized so individual spawn sites (sync.rs fetch_messages,
471/// archive task, sync_dms, subscription_handler) don't have to wrap.
472/// Direct Invites live 24 hours BY DESIGN, and the RECIPIENT enforces it: a
473/// sender that omits the NIP-40 tag (older clients, other implementations)
474/// must not mint a permanent invite, and an archive sync that resurrects a
475/// months-old wrap must not park a ghost (a re-founded community's stale
476/// invite has no held id for the exists-guard to match). The sender's
477/// declared deadline is honored when EARLIER; the rumor-age lifetime is the
478/// ceiling either way. One hour of slack absorbs sender clock skew.
479pub const DIRECT_INVITE_LIFETIME_SECS: u64 = 24 * 3600 + 3600;
480
481fn expired_invite(expires_at: u64, rumor_created_at: u64) -> bool {
482    let now = nostr_sdk::prelude::Timestamp::now().as_secs();
483    if expires_at != 0 && expires_at <= now {
484        return true;
485    }
486    rumor_created_at.saturating_add(DIRECT_INVITE_LIFETIME_SECS) <= now
487}
488
489#[cfg(test)]
490mod invite_expiry_tests {
491    use super::*;
492
493    fn now() -> u64 {
494        nostr_sdk::prelude::Timestamp::now().as_secs()
495    }
496
497    #[test]
498    fn declared_deadline_is_honored() {
499        assert!(expired_invite(now() - 10, now()));
500        assert!(!expired_invite(now() + 3600, now()));
501    }
502
503    #[test]
504    fn tagless_invites_die_at_the_recipient_lifetime() {
505        // Fresh, no tag: parks.
506        assert!(!expired_invite(0, now() - 3600));
507        // A day-and-slack old, no tag: never parks — the class the archive
508        // recovery resurrected (months-old invite to a re-founded community).
509        assert!(expired_invite(0, now() - DIRECT_INVITE_LIFETIME_SECS));
510        assert!(expired_invite(0, now() - 90 * 24 * 3600));
511    }
512
513    #[test]
514    fn lifetime_caps_a_generous_declared_deadline() {
515        // Sender promised a week — the recipient's 24h ceiling still wins.
516        assert!(expired_invite(now() + 7 * 24 * 3600, now() - DIRECT_INVITE_LIFETIME_SECS));
517    }
518}
519
520pub async fn commit_prepared_event(
521    prepared: PreparedEvent,
522    is_new: bool,
523    handler: &dyn InboundEventHandler,
524) -> bool {
525    let session = crate::state::SessionGuard::capture();
526    if !session.is_valid() {
527        return false;
528    }
529    match prepared {
530        PreparedEvent::Processed { result, contact, sender, is_mine, wrapper_event_id, wrapper_event_id_bytes, wrapper_created_at, .. } => {
531            // Cache wrapper for session dedup
532            {
533                let mut cache = WRAPPER_ID_CACHE.lock().await;
534                cache.insert(wrapper_event_id_bytes);
535            }
536
537            // Blocked check — drop content from blocked contacts (wrapper still ledgered so
538            // the dropped content never re-syncs)
539            if !is_mine {
540                let blocked = {
541                    let state = crate::state::STATE.lock().await;
542                    state.get_profile(&contact).map_or(false, |p| p.flags.is_blocked())
543                };
544                if blocked {
545                    let _ = crate::db::wrappers::save_processed_wrapper(&wrapper_event_id_bytes, wrapper_created_at, crate::db::wrappers::TRANSPORT_NIP17);
546                    return false;
547                }
548            }
549
550            // Persist for cross-session dedup + negentropy — EXCEPT message rumors: the
551            // ledger IS the negentropy fingerprint set, and a batching handler may defer the
552            // message's save, so its wrapper must never be ledgered before its row lands (a
553            // ledgered-but-unpersisted message reads as "have" and is never re-delivered).
554            // Message wrappers ledger inside commit_dm_message / the batch-flush transaction.
555            if !matches!(result, RumorProcessingResult::TextMessage(_) | RumorProcessingResult::FileAttachment(_)) {
556                let _ = crate::db::wrappers::save_processed_wrapper(&wrapper_event_id_bytes, wrapper_created_at, crate::db::wrappers::TRANSPORT_NIP17);
557            }
558
559            match result {
560                RumorProcessingResult::TextMessage(mut msg) => {
561                    msg.wrapper_event_id = Some(wrapper_event_id.clone());
562                    commit_dm_message(msg, &contact, is_mine, is_new, &wrapper_event_id, wrapper_event_id_bytes, wrapper_created_at, handler, false).await
563                }
564                RumorProcessingResult::FileAttachment(mut msg) => {
565                    msg.wrapper_event_id = Some(wrapper_event_id.clone());
566                    // If the sender's client (e.g. 0xChat) didn't ship `size` in
567                    // the imeta tag, probe the URL via Content-Length so the
568                    // frontend's auto-download gate has accurate metadata to
569                    // decide on.
570                    //
571                    // Skip for self-echoes (is_mine): we just uploaded these
572                    // files, the local Attachment.size is authoritative, and
573                    // probing our own blossom URL right after upload is a
574                    // correlation-fingerprint privacy regression.
575                    //
576                    // Each probe is bounded by a 3s outer timeout so a slow or
577                    // dead server can't stall the inbound rumor pipeline. If
578                    // the probe times out, we ship size=0 and the frontend
579                    // falls back to a manual "Click to Download" affordance.
580                    if !is_mine {
581                        for att in &mut msg.attachments {
582                            if att.size == 0
583                                && (att.url.starts_with("https://") || att.url.starts_with("http://"))
584                            {
585                                if let Ok(Some(size)) = tokio::time::timeout(
586                                    std::time::Duration::from_secs(3),
587                                    crate::net::get_remote_file_size(&att.url),
588                                ).await {
589                                    att.size = size;
590                                }
591                            }
592                        }
593                    }
594                    commit_dm_message(msg, &contact, is_mine, is_new, &wrapper_event_id, wrapper_event_id_bytes, wrapper_created_at, handler, true).await
595                }
596                RumorProcessingResult::Reaction(reaction) => {
597                    commit_reaction(reaction, &contact, is_mine, &wrapper_event_id, handler).await
598                }
599                RumorProcessingResult::Edit { message_id, new_content, edited_at, emoji_tags, mut event } => {
600                    commit_edit(&mut event, &contact, &message_id, &new_content, edited_at, emoji_tags, &wrapper_event_id).await
601                }
602                RumorProcessingResult::TypingIndicator { profile_id, until } => {
603                    let active_typers = {
604                        let mut state = crate::state::STATE.lock().await;
605                        state.update_typing_and_get_active(&contact, &profile_id, until)
606                    };
607                    crate::traits::emit_event("typing-update", &serde_json::json!({
608                        "conversation_id": contact,
609                        "typers": active_typers,
610                    }));
611                    false
612                }
613                RumorProcessingResult::PivxPayment { gift_code, amount_piv, address, message_id, mut event } => {
614                    if crate::db::events::event_exists(&event.id).unwrap_or(false) {
615                        return false;
616                    }
617                    event.wrapper_event_id = Some(wrapper_event_id.clone());
618                    let ts = event.created_at;
619                    let _ = crate::db::events::save_pivx_payment_event(&contact, event).await;
620                    crate::traits::emit_event("pivx_payment_received", &serde_json::json!({
621                        "conversation_id": contact,
622                        "gift_code": gift_code, "amount_piv": amount_piv,
623                        "address": address, "message_id": message_id,
624                        "sender": sender.to_hex(), "is_mine": is_mine,
625                        "at": ts * 1000,
626                    }));
627                    true
628                }
629                RumorProcessingResult::UnknownEvent(mut event) => {
630                    event.wrapper_event_id = Some(wrapper_event_id.clone());
631                    // Store unknown events for forward compatibility
632                    if let Ok(chat_id) = crate::db::id_cache::get_or_create_chat_id(&contact) {
633                        event.chat_id = chat_id;
634                    }
635                    let _ = crate::db::events::save_event(&event).await;
636                    false
637                }
638                RumorProcessingResult::LeaveRequest { .. } => false,
639                RumorProcessingResult::WebxdcPeerAdvertisement { .. } |
640                RumorProcessingResult::WebxdcPeerLeft { .. } => {
641                    // WebXDC is platform-specific — handled by src-tauri directly
642                    false
643                }
644                RumorProcessingResult::WallpaperChanged {
645                    sender_npub, created_at, url, decryption_key, decryption_nonce,
646                    plaintext_hash, mime, blur, dim, event_id,
647                } => {
648                    let _ = crate::wallpaper::apply_received_wallpaper(
649                        &contact, &sender_npub, created_at, &url,
650                        &decryption_key, &decryption_nonce,
651                        plaintext_hash.as_deref(), mime.as_deref(),
652                        blur, dim,
653                        &event_id,
654                    ).await;
655                    // System event is saved inside apply_received_wallpaper.
656                    // Return true so the caller treats this as a stored event.
657                    true
658                }
659                RumorProcessingResult::DeletionRequest { target_event_id } => {
660                    // A deletion targets a message OR a reaction (both event ids,
661                    // never colliding). Try the message path; if it's not a known
662                    // message, treat it as a reaction revocation.
663                    if commit_deletion(&target_event_id, &contact, &sender, handler).await {
664                        true
665                    } else {
666                        commit_reaction_deletion(&target_event_id, &sender).await
667                    }
668                }
669                RumorProcessingResult::Ignored => false,
670            }
671        }
672        PreparedEvent::CommunityInvite { invite, inviter, is_mine, wrapper_event_id_bytes, wrapper_created_at, rumor_created_at, expires_at } => {
673            // Negentropy bookkeeping regardless of outcome (the outer wrapper id is
674            // attacker-controlled, so it can't be the join-idempotency key — see below).
675            {
676                let mut cache = WRAPPER_ID_CACHE.lock().await;
677                cache.insert(wrapper_event_id_bytes);
678            }
679            let _ = crate::db::wrappers::save_processed_wrapper(&wrapper_event_id_bytes, wrapper_created_at, crate::db::wrappers::TRANSPORT_NIP17);
680
681            // Never park our own echoed invite.
682            if is_mine {
683                return false;
684            }
685
686            // Past the sender's deadline OR past the recipient-enforced 24h
687            // lifetime (a catch-up sync of a stale wrap, a relay that ignores
688            // expiry, a sender that never set the tag): never park it.
689            if expired_invite(expires_at, rumor_created_at) {
690                return false;
691            }
692
693            // Cap-check before touching the DB (a hostile bundle can declare an
694            // unbounded channel/relay list).
695            if let Err(e) = invite.validate() {
696                log_warn!("[community] invite rejected: {}", e);
697                return false;
698            }
699
700            // Idempotency on the INNER identity (community_id), NOT the wrapper id: a
701            // replayed bundle re-wrapped under fresh ephemeral keys must not re-notify
702            // or churn. If we already hold this Community, or already have it parked,
703            // drop silently.
704            let community_id = invite.community_id.clone();
705            // PUBLIC input: a signature-valid invite can still carry a malformed id, so decode through
706            // the SIMD-validated path (rejects non-hex / wrong length in-register).
707            let already_held = crate::community::CommunityId(
708                match crate::simd::hex::hex_to_bytes_32_checked(&community_id) {
709                    Some(b) => b,
710                    None => { log_warn!("[community] invite has malformed id"); return false; }
711                },
712            );
713            if crate::db::community::community_exists(&already_held).unwrap_or(false) {
714                return false;
715            }
716            if crate::db::community::pending_invite_exists(&community_id).unwrap_or(false) {
717                return false;
718            }
719
720            // Supersession: a decline/leave tombstone suppresses any invite no newer than the
721            // decision (so the un-deletable 3304 can't re-nag, and a sibling's decline propagated via
722            // the synced list silences this device too). A STRICTLY-newer invite falls through and
723            // parks — a deliberate re-invite resurfaces. Ordered on the inner rumor time, not the
724            // NIP-59-backdated wrapper (which would make a fresh re-invite look older than the decline).
725            if crate::community::list::tombstone_suppresses(&community_id, rumor_created_at) {
726                return false;
727            }
728
729            // Park for explicit consent — do NOT join, subscribe, or dial the bundle's
730            // relays here. The user accepts via the command layer.
731            let bundle_json = match invite.to_json() {
732                Ok(j) => j,
733                Err(e) => { log_warn!("[community] invite re-serialize failed: {}", e); return false; }
734            };
735            match crate::db::community::save_pending_invite(&community_id, &bundle_json, &inviter, expires_at as i64) {
736                Ok(true) => {
737                    handler.on_community_invite(&community_id);
738                    // Warm the community's first page in the background so a subsequent Accept opens
739                    // populated instead of paying the join sync. RAM-only + best-effort; promotion on
740                    // Join re-validates freshness. SessionGuard'd so a mid-flight swap is a no-op.
741                    let invite_warm = invite.clone();
742                    let bg = crate::state::SessionGuard::capture();
743                    tokio::spawn(async move {
744                        if !bg.is_valid() {
745                            return;
746                        }
747                        crate::community::service::preload_community(&invite_warm).await;
748                    });
749                }
750                Ok(false) => {} // raced — already parked
751                Err(e) => log_warn!("[community] invite park failed: {}", e),
752            }
753            false
754        }
755        PreparedEvent::CommunityInviteV2 { bundle_json, community_id, inviter, is_mine, wrapper_event_id_bytes, wrapper_created_at, rumor_created_at, expires_at } => {
756            {
757                let mut cache = WRAPPER_ID_CACHE.lock().await;
758                cache.insert(wrapper_event_id_bytes);
759            }
760            let _ = crate::db::wrappers::save_processed_wrapper(&wrapper_event_id_bytes, wrapper_created_at, crate::db::wrappers::TRANSPORT_NIP17);
761
762            // Never park our own echoed invite.
763            if is_mine {
764                return false;
765            }
766            // Past the sender's deadline or the 24h lifetime — see the v1 arm.
767            if expired_invite(expires_at, rumor_created_at) {
768                return false;
769            }
770            // Idempotency on the INNER community_id (already validated hex), not the
771            // attacker-controlled wrapper id: already held, or already parked → drop.
772            let held = match crate::simd::hex::hex_to_bytes_32_checked(&community_id) {
773                Some(b) => crate::community::CommunityId(b),
774                None => return false,
775            };
776            if crate::db::community::community_exists(&held).unwrap_or(false) {
777                return false;
778            }
779            if crate::db::community::pending_invite_exists(&community_id).unwrap_or(false) {
780                return false;
781            }
782            // Supersession: a decline/leave tombstone (protocol-agnostic, keyed on
783            // community_id) suppresses a re-wrapped invite no newer than the decision,
784            // so a declined/left community can't be re-nagged by a fresh ephemeral wrap.
785            // Ordered on the inner rumor time, not the NIP-59-backdated wrapper.
786            if crate::community::list::tombstone_suppresses(&community_id, rumor_created_at) {
787                return false;
788            }
789            // Park for explicit consent — do NOT join/subscribe here. Accept via the command layer.
790            match crate::db::community::save_pending_invite(&community_id, &bundle_json, &inviter, expires_at as i64) {
791                Ok(true) => handler.on_community_invite(&community_id),
792                Ok(false) => {} // raced — already parked
793                Err(e) => log_warn!("[community] v2 invite park failed: {}", e),
794            }
795            false
796        }
797        PreparedEvent::DedupSkip { wrapper_id_bytes, wrapper_created_at } => {
798            // Persist wrapper timestamp for negentropy backfill (skip no-op writes).
799            // Guarded: a cache-hit skip can name a wrapper whose message is still sitting in
800            // a batch buffer (deferred ledger) — inserting it here would mark the message
801            // "have" before its row exists. Only touch the ledger when the wrapper is
802            // already ledgered (timestamp backfill) or its row is verifiably persisted
803            // (legacy pre-ledger events, whose wrapper lives only on the events row).
804            if wrapper_created_at > 0 {
805                if crate::db::wrappers::processed_wrapper_exists(&wrapper_id_bytes) {
806                    let _ = crate::db::wrappers::update_wrapper_timestamp(&wrapper_id_bytes, wrapper_created_at);
807                } else {
808                    let wrapper_hex = crate::simd::hex::bytes_to_hex_32(&wrapper_id_bytes);
809                    if crate::db::events::wrapper_event_exists(&wrapper_hex).unwrap_or(false) {
810                        let _ = crate::db::wrappers::save_processed_wrapper(&wrapper_id_bytes, wrapper_created_at, crate::db::wrappers::TRANSPORT_NIP17);
811                    }
812                }
813            }
814            false
815        }
816        PreparedEvent::ErrorSkip { wrapper_id_bytes, wrapper_created_at } => {
817            let _ = crate::db::wrappers::save_processed_wrapper(&wrapper_id_bytes, wrapper_created_at, crate::db::wrappers::TRANSPORT_NIP17);
818            false
819        }
820    }
821}
822
823/// Commit a DM text or file message (shared logic for both).
824///
825/// Owns this message's wrapper-ledger write (`processed_wrappers` = the negentropy
826/// fingerprint set): the wrapper is ledgered only once the message row is durably handled —
827/// immediately after a successful save, inside the batch-flush transaction when a handler
828/// defers, or right away when the message is a known duplicate. An unledgered wrapper is
829/// re-delivered by the next reconciliation, which is what makes a dropped batch recoverable.
830#[allow(clippy::too_many_arguments)]
831async fn commit_dm_message(
832    mut msg: Message,
833    contact: &str,
834    _is_mine: bool,
835    is_new: bool,
836    wrapper_event_id: &str,
837    wrapper_event_id_bytes: [u8; 32],
838    wrapper_created_at: u64,
839    handler: &dyn InboundEventHandler,
840    is_file: bool,
841) -> bool {
842    let ledger_wrapper = || {
843        let _ = crate::db::wrappers::save_processed_wrapper(
844            &wrapper_event_id_bytes, wrapper_created_at, crate::db::wrappers::TRANSPORT_NIP17,
845        );
846    };
847    // Dedup: check if message already in DB
848    if let Ok(true) = crate::db::events::message_exists_in_db(&msg.id) {
849        // Already in DB — try to backfill wrapper_event_id
850        if let Ok(updated) = crate::db::events::update_wrapper_event_id(&msg.id, wrapper_event_id) {
851            if !updated {
852                let mut cache = WRAPPER_ID_CACHE.lock().await;
853                cache.insert(wrapper_event_id_bytes);
854            }
855        }
856        ledger_wrapper();
857        return false;
858    }
859
860    // Populate reply context
861    if !msg.replied_to.is_empty() {
862        let _ = crate::db::events::populate_reply_context(&mut msg).await;
863    }
864
865    // Add to STATE (+ clear typing indicator for file senders)
866    let added = {
867        let mut state = crate::state::STATE.lock().await;
868        let added = state.add_message_to_participant(contact, &msg);
869        if is_file && added {
870            state.update_typing_and_get_active(contact, contact, 0);
871        }
872        added
873    };
874
875    if added {
876        // Emit to frontend
877        crate::traits::emit_event("message_new", &serde_json::json!({
878            "message": &msg,
879            "chat_id": contact
880        }));
881
882        // Platform callback (notifications, badge, etc.)
883        if is_file {
884            handler.on_file_received(contact, &msg, is_new);
885        } else {
886            handler.on_dm_received(contact, &msg, is_new);
887        }
888
889        // Save to DB — unless a bulk-sync handler owns batched persistence (the handler then
890        // also owns the wrapper-ledger write, inside its flush transaction). On the immediate
891        // path the wrapper ledgers only after a successful save: a failed save left unledgered
892        // re-delivers on the next reconciliation instead of being lost.
893        if !handler.buffer_persist(contact, &msg, Some((wrapper_event_id_bytes, wrapper_created_at))) {
894            if crate::db::events::save_message(contact, &msg).await.is_ok() {
895                ledger_wrapper();
896            }
897        }
898    } else {
899        // STATE-level duplicate: a same-session twin owns the row; this wrapper carried
900        // nothing new, so ledger it now (parity with the old eager ledger).
901        ledger_wrapper();
902    }
903
904    added
905}
906
907/// Commit a reaction event.
908async fn commit_reaction(
909    reaction: crate::types::Reaction,
910    contact: &str,
911    is_mine: bool,
912    wrapper_event_id: &str,
913    handler: &dyn InboundEventHandler,
914) -> bool {
915    // Add to STATE
916    let msg_for_emit = {
917        let mut state = crate::state::STATE.lock().await;
918        if let Some((chat_id, was_added)) = state.add_reaction_to_message(&reaction.reference_id, reaction.clone()) {
919            if was_added {
920                state.find_message(&reaction.reference_id)
921                    .map(|(_, msg)| (chat_id, msg))
922            } else { None }
923        } else { None }
924    };
925
926    if let Some((chat_id, mut msg)) = msg_for_emit {
927        crate::traits::emit_message_update(&chat_id, &reaction.reference_id, &mut msg).await;
928        let _ = crate::db::events::save_message(&chat_id, &msg).await;
929        handler.on_reaction_received(&chat_id, &msg);
930    }
931
932    // Always save reaction event with wrapper for dedup
933    if let Ok(chat_id) = crate::db::id_cache::get_chat_id_by_identifier(contact) {
934        let _ = crate::db::events::save_reaction_event(
935            &reaction, chat_id, None, is_mine, Some(wrapper_event_id.to_string())
936        ).await;
937    }
938
939    true
940}
941
942/// Commit a message edit.
943async fn commit_edit(
944    event: &mut crate::stored_event::StoredEvent,
945    contact: &str,
946    message_id: &str,
947    new_content: &str,
948    edited_at: u64,
949    emoji_tags: Vec<crate::types::EmojiTag>,
950    wrapper_event_id: &str,
951) -> bool {
952    if crate::db::events::event_exists(&event.id).unwrap_or(false) {
953        return false;
954    }
955    if let Ok(chat_id) = crate::db::id_cache::get_chat_id_by_identifier(contact) {
956        event.chat_id = chat_id;
957    }
958    event.wrapper_event_id = Some(wrapper_event_id.to_string());
959    let _ = crate::db::events::save_event(event).await;
960
961    let msg_for_emit = {
962        let mut state = crate::state::STATE.lock().await;
963        state.update_message_in_chat(contact, message_id, |msg| {
964            msg.apply_edit(new_content.to_string(), edited_at, emoji_tags.clone());
965        })
966    };
967    if let Some(mut msg) = msg_for_emit {
968        crate::traits::emit_message_update(contact, message_id, &mut msg).await;
969    }
970    true
971}
972
973/// Commit a NIP-09 cooperative deletion request.
974///
975/// Authorization: only the original message's author can delete it
976/// (matches NIP-09's `event.pubkey == deletion.pubkey` rule applied to
977/// the inner rumor). For DMs, that means either the sender is `MY` (we
978/// deleted from another device) or the sender is the chat counterpart
979/// who originally sent that message. Anyone else's deletion is silently
980/// ignored.
981///
982/// On success: drops the message from in-memory STATE, removes the row
983/// from the events table, and emits `message_removed` so the frontend
984/// can fade the row out — same code path as failed-message cleanup.
985async fn commit_deletion(
986    target_event_id: &str,
987    contact: &str,
988    sender: &PublicKey,
989    handler: &dyn InboundEventHandler,
990) -> bool {
991    // Look up the original. If not present locally there's nothing to
992    // delete — the deletion notice arrived before the original (rare),
993    // or we never had it. Either way, no-op.
994    //
995    // KNOWN LIMITATION: late-binding deletions are not handled. If
996    // the deletion arrives BEFORE the original (cold sync, out-of-order
997    // relay delivery), we drop the deletion silently here, and when the
998    // original arrives later it shows up unhidden. A future enhancement
999    // would persist a `pending_deletions` table keyed by target id and
1000    // apply queued deletions when the target is committed in
1001    // commit_dm_message. The common case (deletion arrives after the
1002    // original) works correctly today.
1003    //
1004    // For DM rumors the `npub` field is intentionally empty: the chat
1005    // is between two parties, so the author is implicit from `mine`
1006    // (me if true, chat counterpart if false). We derive the original
1007    // author from that, since the rumor pubkey isn't stored.
1008    let (mine, chat_id) = {
1009        let state = crate::state::STATE.lock().await;
1010        match state.find_message(target_event_id) {
1011            Some((chat, msg)) => (msg.mine, chat.id.clone()),
1012            None => return false,
1013        }
1014    };
1015
1016    // Authorization: deletion sender must match the original author.
1017    // For DMs:
1018    //   - mine == true:  original author == us (MY_PUBLIC_KEY).
1019    //                    Authorized if the deletion sender is also us
1020    //                    (i.e. came in via our own self-wrap from
1021    //                    another device, multi-device sync).
1022    //   - mine == false: original author == chat counterpart. Chat id
1023    //                    for a DM is the counterpart's npub, so we
1024    //                    parse it and compare against the deletion
1025    //                    sender.
1026    let authorized = if mine {
1027        match crate::state::my_public_key() {
1028            Some(my_pk) => *sender == my_pk,
1029            None => false,
1030        }
1031    } else {
1032        match nostr_sdk::prelude::PublicKey::from_bech32(&chat_id) {
1033            Ok(counterpart) => sender == &counterpart,
1034            Err(_) => false, // chat id wasn't an npub (shouldn't happen for DMs)
1035        }
1036    };
1037    if !authorized {
1038        eprintln!(
1039            "[NIP-17 cooperative-delete] unauthorized: sender {} not the author of target {} (mine={}, chat={})",
1040            sender.to_hex(), target_event_id, mine, chat_id
1041        );
1042        return false;
1043    }
1044
1045    // Drop from in-memory state.
1046    let removed = {
1047        let mut state = crate::state::STATE.lock().await;
1048        state.remove_message(target_event_id)
1049    };
1050    let removed_msg = match removed {
1051        Some((_chat_id, msg)) => msg,
1052        None => return false,
1053    };
1054    // Tombstone BEFORE the row delete: the target may sit unflushed in a sync batch buffer
1055    // (delete_event below would no-op) — the flush consults this and drops it.
1056    crate::state::note_message_deleted(target_event_id);
1057
1058    // Nuke any cached attachment files for this message — sender asked
1059    // for the message to disappear, and a downloaded file the receiver
1060    // never moved out of Vector's cache should go with it.
1061    //
1062    // Refcount filter: drop attachments still referenced by sibling
1063    // messages so we don't yank a cached file from messages that
1064    // still need it (Vector dedupes by SHA-256, so the same file
1065    // can back multiple messages). User-managed paths are also left
1066    // alone (canonicalize + starts_with check).
1067    let unique = crate::deletion::filter_unreferenced_attachments(
1068        target_event_id,
1069        removed_msg.attachments,
1070    ).await;
1071    crate::deletion::delete_cached_attachment_files_pub(&unique);
1072
1073    // Drop from the events table.
1074    if let Err(e) = crate::db::events::delete_event(target_event_id).await {
1075        eprintln!(
1076            "[NIP-17 cooperative-delete] DB delete failed for {}: {}",
1077            target_event_id, e
1078        );
1079    }
1080
1081    // Tell the frontend to fade the row out. Reuses the existing
1082    // message_removed event handled in main.js, so no new wiring.
1083    crate::traits::emit_event(
1084        "message_removed",
1085        &serde_json::json!({
1086            "id": target_event_id,
1087            "chat_id": &chat_id,
1088            "reason": "deleted-by-sender",
1089        }),
1090    );
1091
1092    handler.on_message_deleted(&chat_id, target_event_id);
1093    let _ = contact;
1094    true
1095}
1096
1097/// Apply a cooperative reaction revocation (NIP-09 k=7) from the reaction's
1098/// author. Removes the reaction from its parent message and drops the kind-7
1099/// row, then live-refreshes the parent's chips. Returns false if we don't hold
1100/// the reaction or the sender isn't its author.
1101async fn commit_reaction_deletion(target_reaction_id: &str, sender: &PublicKey) -> bool {
1102    let found = {
1103        let state = crate::state::STATE.lock().await;
1104        state.find_reaction(target_reaction_id)
1105    };
1106    let (chat_id, message_id, author_npub, _is_community) = match found {
1107        Some(v) => v,
1108        None => return false,
1109    };
1110
1111    // Authorization: only the reaction's own author may revoke it.
1112    let authorized = nostr_sdk::prelude::PublicKey::parse(&author_npub)
1113        .map(|pk| pk == *sender)
1114        .unwrap_or(false);
1115    if !authorized {
1116        eprintln!(
1117            "[reaction-delete] unauthorized: sender {} is not the author of reaction {}",
1118            sender.to_hex(), target_reaction_id
1119        );
1120        return false;
1121    }
1122
1123    let updated = {
1124        let mut state = crate::state::STATE.lock().await;
1125        state.remove_reaction_from_message(&message_id, target_reaction_id)
1126    };
1127    let mut message = match updated {
1128        Some((_cid, msg)) => msg,
1129        None => return false,
1130    };
1131
1132    // save_message is additive for reactions, so the kind-7 row must be
1133    // dropped explicitly or it resurrects on reload.
1134    if let Err(e) = crate::db::events::delete_event(target_reaction_id).await {
1135        eprintln!("[reaction-delete] DB delete failed for {}: {}", target_reaction_id, e);
1136    }
1137
1138    crate::traits::emit_message_update(&chat_id, &message_id, &mut message).await;
1139    true
1140}
1141
1142// ============================================================================
1143// Convenience: single-call event processing
1144// ============================================================================
1145
1146/// Process a single event through the full pipeline (prepare + commit).
1147///
1148/// Gets client and public key from globals. For callers that manage their
1149/// own notification loop but want the full vector-core processing pipeline.
1150pub async fn process_event(
1151    event: Event,
1152    is_new: bool,
1153    handler: &dyn InboundEventHandler,
1154) -> std::result::Result<bool, String> {
1155    let client = crate::state::nostr_client()
1156        .ok_or_else(|| "Nostr client not initialized".to_string())?;
1157    let my_pk = crate::state::my_public_key()
1158        .ok_or_else(|| "Public key not initialized".to_string())?;
1159    let prepared = prepare_event(event, &client, my_pk).await;
1160    Ok(commit_prepared_event(prepared, is_new, handler).await)
1161}