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
92/// No-op handler for CLI/tests.
93pub struct NoOpEventHandler;
94impl InboundEventHandler for NoOpEventHandler {}
95
96/// Result of Phase 1 (prepare_event) — everything needed for sequential commit.
97pub enum PreparedEvent {
98    /// Fully processed DM rumor — ready for state commit.
99    Processed {
100        result: RumorProcessingResult,
101        contact: String,
102        sender: PublicKey,
103        is_mine: bool,
104        wrapper_event_id: String,
105        wrapper_event_id_bytes: [u8; 32],
106        wrapper_created_at: u64,
107        /// Time spent on ECDH + ChaCha20Poly1305 decryption (nanoseconds)
108        unwrap_ns: u64,
109        /// Time spent on rumor parsing (nanoseconds)
110        parse_ns: u64,
111    },
112    /// Community invite bundle (kind 3304) — parked for explicit user consent.
113    CommunityInvite {
114        invite: crate::community::invite::CommunityInvite,
115        /// Inviter's npub (bech32) — shown in the pending-invite UI.
116        inviter: String,
117        is_mine: bool,
118        wrapper_event_id_bytes: [u8; 32],
119        wrapper_created_at: u64,
120    },
121    /// Concord v2 Direct Invite (inner kind 3313) — parked for explicit consent.
122    /// Carries the raw bundle JSON (parked verbatim; the accept path re-parses it).
123    CommunityInviteV2 {
124        bundle_json: String,
125        community_id: String,
126        /// Inviter's npub (hex) — the proven seal signer.
127        inviter: String,
128        is_mine: bool,
129        wrapper_event_id_bytes: [u8; 32],
130        wrapper_created_at: u64,
131    },
132    /// Duplicate event — just persist wrapper for negentropy.
133    DedupSkip {
134        wrapper_id_bytes: [u8; 32],
135        wrapper_created_at: u64,
136    },
137    /// Error during unwrap/processing — persist wrapper for negentropy.
138    ErrorSkip {
139        wrapper_id_bytes: [u8; 32],
140        wrapper_created_at: u64,
141    },
142}
143
144/// Phase 1: Prepare an event for commit (parallel-safe, no state mutation).
145///
146/// Performs dedup check, gift wrap decryption, and rumor parsing.
147/// Safe to call from multiple tokio tasks concurrently.
148pub async fn prepare_event(
149    event: Event,
150    client: &Client,
151    my_public_key: PublicKey,
152) -> PreparedEvent {
153    let wrapper_created_at = event.created_at.as_secs();
154    let wrapper_event_id_bytes: [u8; 32] = event.id.to_bytes();
155    let wrapper_event_id = event.id.to_hex();
156
157    // Dedup: in-memory cache first, then DB fallback
158    {
159        let cache = WRAPPER_ID_CACHE.lock().await;
160        if cache.contains(&wrapper_event_id_bytes) {
161            return PreparedEvent::DedupSkip { wrapper_id_bytes: wrapper_event_id_bytes, wrapper_created_at };
162        }
163    }
164
165    if let Ok(true) = crate::db::events::wrapper_event_exists(&wrapper_event_id) {
166        return PreparedEvent::DedupSkip { wrapper_id_bytes: wrapper_event_id_bytes, wrapper_created_at };
167    }
168
169    // Unwrap gift wrap (CPU-bound ECDH + ChaCha20Poly1305)
170    let unwrap_start = std::time::Instant::now();
171    let (rumor, sender) = match client.unwrap_gift_wrap(&event).await {
172        Ok(UnwrappedGift { rumor, sender }) => (rumor, sender),
173        Err(_) => return PreparedEvent::ErrorSkip {
174            wrapper_id_bytes: wrapper_event_id_bytes, wrapper_created_at,
175        },
176    };
177
178    let unwrap_ns = unwrap_start.elapsed().as_nanos() as u64;
179
180    let is_mine = sender == my_public_key;
181    let contact = if is_mine {
182        rumor.tags.public_keys().next()
183            .and_then(|pk| pk.to_bech32().ok())
184            .unwrap_or_else(|| sender.to_bech32().unwrap_or_default())
185    } else {
186        sender.to_bech32().unwrap_or_default()
187    };
188
189    // Skip NIP-17 group messages (multiple p-tags) — Vector DMs are 1:1
190    if rumor.tags.public_keys().count() > 1 {
191        return PreparedEvent::ErrorSkip {
192            wrapper_id_bytes: wrapper_event_id_bytes, wrapper_created_at,
193        };
194    }
195
196    // Community invite (carrier) — a join, not a chat message. Recognized before
197    // process_rumor so it never lands as an UnknownEvent in the DM thread.
198    if rumor.kind == Kind::Custom(crate::stored_event::event_kind::COMMUNITY_INVITE_BUNDLE) {
199        return match crate::community::invite::parse_invite_rumor(rumor.kind, &rumor.content) {
200            Some(invite) => PreparedEvent::CommunityInvite {
201                invite, inviter: contact.clone(), is_mine, wrapper_event_id_bytes, wrapper_created_at,
202            },
203            None => PreparedEvent::ErrorSkip { wrapper_id_bytes: wrapper_event_id_bytes, wrapper_created_at },
204        };
205    }
206
207    // Concord v2 Direct Invite (inner kind 3313) — the v2 join carrier. `from_bundle_json`
208    // validates the owner commitment + bounds; we park the canonical re-serialized bundle.
209    if rumor.kind == Kind::Custom(crate::community::v2::kind::DIRECT_INVITE) {
210        return match crate::community::v2::invite::CommunityInvite::from_bundle_json(&rumor.content)
211            .ok()
212            .and_then(|b| serde_json::to_string(&b).ok().map(|j| (b.community_id, j)))
213        {
214            Some((community_id, bundle_json)) => PreparedEvent::CommunityInviteV2 {
215                community_id,
216                bundle_json,
217                inviter: contact.clone(), // the seal signer's npub (bech32), like the v1 arm
218                is_mine,
219                wrapper_event_id_bytes,
220                wrapper_created_at,
221            },
222            None => PreparedEvent::ErrorSkip { wrapper_id_bytes: wrapper_event_id_bytes, wrapper_created_at },
223        };
224    }
225
226    // Build RumorEvent for processing
227    let Some(rumor_id) = rumor.id else {
228        return PreparedEvent::ErrorSkip {
229            wrapper_id_bytes: wrapper_event_id_bytes, wrapper_created_at,
230        };
231    };
232
233    let rumor_event = RumorEvent {
234        id: rumor_id,
235        kind: rumor.kind,
236        content: rumor.content,
237        tags: rumor.tags,
238        created_at: rumor.created_at,
239        pubkey: rumor.pubkey,
240    };
241    let rumor_context = RumorContext {
242        sender,
243        is_mine,
244        conversation_id: contact.clone(),
245        conversation_type: ConversationType::DirectMessage,
246    };
247
248    let parse_start = std::time::Instant::now();
249    let download_dir = crate::db::get_download_dir();
250    match process_rumor(rumor_event, rumor_context, &download_dir) {
251        Ok(result) => {
252            let parse_ns = parse_start.elapsed().as_nanos() as u64;
253            PreparedEvent::Processed {
254                result, contact, sender, is_mine,
255                wrapper_event_id, wrapper_event_id_bytes, wrapper_created_at,
256                unwrap_ns, parse_ns,
257            }
258        }
259        Err(e) => {
260            log_warn!("[EventHandler] Failed to process rumor: {}", e);
261            PreparedEvent::ErrorSkip {
262                wrapper_id_bytes: wrapper_event_id_bytes, wrapper_created_at,
263            }
264        }
265    }
266}
267
268// ============================================================================
269// Phase 2: Commit — sequential state mutation, DB save, emit
270// ============================================================================
271
272/// Phase 2: commit a prepared event (sequential — not parallel-safe).
273/// Saves to DB, updates STATE, emits to frontend, calls handler hooks.
274/// Returns true if a new displayable message was committed.
275///
276/// Session-safety: captures the generation at the first line. If a swap
277/// occurred between `prepare_event()` and here (e.g. long-running
278/// negentropy fetch queued events for commit), bail before any STATE /
279/// DB write. Centralized so individual spawn sites (sync.rs fetch_messages,
280/// archive task, sync_dms, subscription_handler) don't have to wrap.
281pub async fn commit_prepared_event(
282    prepared: PreparedEvent,
283    is_new: bool,
284    handler: &dyn InboundEventHandler,
285) -> bool {
286    let session = crate::state::SessionGuard::capture();
287    if !session.is_valid() {
288        return false;
289    }
290    match prepared {
291        PreparedEvent::Processed { result, contact, sender, is_mine, wrapper_event_id, wrapper_event_id_bytes, wrapper_created_at, .. } => {
292            // Cache wrapper for session dedup
293            {
294                let mut cache = WRAPPER_ID_CACHE.lock().await;
295                cache.insert(wrapper_event_id_bytes);
296            }
297            // Persist for cross-session dedup + negentropy
298            let _ = crate::db::wrappers::save_processed_wrapper(&wrapper_event_id_bytes, wrapper_created_at, crate::db::wrappers::TRANSPORT_NIP17);
299
300            // Blocked check — drop content from blocked contacts (wrapper still persisted for negentropy)
301            if !is_mine {
302                let state = crate::state::STATE.lock().await;
303                if state.get_profile(&contact).map_or(false, |p| p.flags.is_blocked()) {
304                    return false;
305                }
306            }
307
308            match result {
309                RumorProcessingResult::TextMessage(mut msg) => {
310                    msg.wrapper_event_id = Some(wrapper_event_id.clone());
311                    commit_dm_message(msg, &contact, is_mine, is_new, &wrapper_event_id, wrapper_event_id_bytes, handler, false).await
312                }
313                RumorProcessingResult::FileAttachment(mut msg) => {
314                    msg.wrapper_event_id = Some(wrapper_event_id.clone());
315                    // If the sender's client (e.g. 0xChat) didn't ship `size` in
316                    // the imeta tag, probe the URL via Content-Length so the
317                    // frontend's auto-download gate has accurate metadata to
318                    // decide on.
319                    //
320                    // Skip for self-echoes (is_mine): we just uploaded these
321                    // files, the local Attachment.size is authoritative, and
322                    // probing our own blossom URL right after upload is a
323                    // correlation-fingerprint privacy regression.
324                    //
325                    // Each probe is bounded by a 3s outer timeout so a slow or
326                    // dead server can't stall the inbound rumor pipeline. If
327                    // the probe times out, we ship size=0 and the frontend
328                    // falls back to a manual "Click to Download" affordance.
329                    if !is_mine {
330                        for att in &mut msg.attachments {
331                            if att.size == 0
332                                && (att.url.starts_with("https://") || att.url.starts_with("http://"))
333                            {
334                                if let Ok(Some(size)) = tokio::time::timeout(
335                                    std::time::Duration::from_secs(3),
336                                    crate::net::get_remote_file_size(&att.url),
337                                ).await {
338                                    att.size = size;
339                                }
340                            }
341                        }
342                    }
343                    commit_dm_message(msg, &contact, is_mine, is_new, &wrapper_event_id, wrapper_event_id_bytes, handler, true).await
344                }
345                RumorProcessingResult::Reaction(reaction) => {
346                    commit_reaction(reaction, &contact, is_mine, &wrapper_event_id, handler).await
347                }
348                RumorProcessingResult::Edit { message_id, new_content, edited_at, emoji_tags, mut event } => {
349                    commit_edit(&mut event, &contact, &message_id, &new_content, edited_at, emoji_tags, &wrapper_event_id).await
350                }
351                RumorProcessingResult::TypingIndicator { profile_id, until } => {
352                    let active_typers = {
353                        let mut state = crate::state::STATE.lock().await;
354                        state.update_typing_and_get_active(&contact, &profile_id, until)
355                    };
356                    crate::traits::emit_event("typing-update", &serde_json::json!({
357                        "conversation_id": contact,
358                        "typers": active_typers,
359                    }));
360                    false
361                }
362                RumorProcessingResult::PivxPayment { gift_code, amount_piv, address, message_id, mut event } => {
363                    if crate::db::events::event_exists(&event.id).unwrap_or(false) {
364                        return false;
365                    }
366                    event.wrapper_event_id = Some(wrapper_event_id.clone());
367                    let ts = event.created_at;
368                    let _ = crate::db::events::save_pivx_payment_event(&contact, event).await;
369                    crate::traits::emit_event("pivx_payment_received", &serde_json::json!({
370                        "conversation_id": contact,
371                        "gift_code": gift_code, "amount_piv": amount_piv,
372                        "address": address, "message_id": message_id,
373                        "sender": sender.to_hex(), "is_mine": is_mine,
374                        "at": ts * 1000,
375                    }));
376                    true
377                }
378                RumorProcessingResult::UnknownEvent(mut event) => {
379                    event.wrapper_event_id = Some(wrapper_event_id.clone());
380                    // Store unknown events for forward compatibility
381                    if let Ok(chat_id) = crate::db::id_cache::get_or_create_chat_id(&contact) {
382                        event.chat_id = chat_id;
383                    }
384                    let _ = crate::db::events::save_event(&event).await;
385                    false
386                }
387                RumorProcessingResult::LeaveRequest { .. } => false,
388                RumorProcessingResult::WebxdcPeerAdvertisement { .. } |
389                RumorProcessingResult::WebxdcPeerLeft { .. } => {
390                    // WebXDC is platform-specific — handled by src-tauri directly
391                    false
392                }
393                RumorProcessingResult::WallpaperChanged {
394                    sender_npub, created_at, url, decryption_key, decryption_nonce,
395                    plaintext_hash, mime, blur, dim, event_id,
396                } => {
397                    let _ = crate::wallpaper::apply_received_wallpaper(
398                        &contact, &sender_npub, created_at, &url,
399                        &decryption_key, &decryption_nonce,
400                        plaintext_hash.as_deref(), mime.as_deref(),
401                        blur, dim,
402                        &event_id,
403                    ).await;
404                    // System event is saved inside apply_received_wallpaper.
405                    // Return true so the caller treats this as a stored event.
406                    true
407                }
408                RumorProcessingResult::DeletionRequest { target_event_id } => {
409                    // A deletion targets a message OR a reaction (both event ids,
410                    // never colliding). Try the message path; if it's not a known
411                    // message, treat it as a reaction revocation.
412                    if commit_deletion(&target_event_id, &contact, &sender, handler).await {
413                        true
414                    } else {
415                        commit_reaction_deletion(&target_event_id, &sender).await
416                    }
417                }
418                RumorProcessingResult::Ignored => false,
419            }
420        }
421        PreparedEvent::CommunityInvite { invite, inviter, is_mine, wrapper_event_id_bytes, wrapper_created_at } => {
422            // Negentropy bookkeeping regardless of outcome (the outer wrapper id is
423            // attacker-controlled, so it can't be the join-idempotency key — see below).
424            {
425                let mut cache = WRAPPER_ID_CACHE.lock().await;
426                cache.insert(wrapper_event_id_bytes);
427            }
428            let _ = crate::db::wrappers::save_processed_wrapper(&wrapper_event_id_bytes, wrapper_created_at, crate::db::wrappers::TRANSPORT_NIP17);
429
430            // Never park our own echoed invite.
431            if is_mine {
432                return false;
433            }
434
435            // Cap-check before touching the DB (a hostile bundle can declare an
436            // unbounded channel/relay list).
437            if let Err(e) = invite.validate() {
438                log_warn!("[community] invite rejected: {}", e);
439                return false;
440            }
441
442            // Idempotency on the INNER identity (community_id), NOT the wrapper id: a
443            // replayed bundle re-wrapped under fresh ephemeral keys must not re-notify
444            // or churn. If we already hold this Community, or already have it parked,
445            // drop silently.
446            let community_id = invite.community_id.clone();
447            // PUBLIC input: a signature-valid invite can still carry a malformed id, so decode through
448            // the SIMD-validated path (rejects non-hex / wrong length in-register).
449            let already_held = crate::community::CommunityId(
450                match crate::simd::hex::hex_to_bytes_32_checked(&community_id) {
451                    Some(b) => b,
452                    None => { log_warn!("[community] invite has malformed id"); return false; }
453                },
454            );
455            if crate::db::community::community_exists(&already_held).unwrap_or(false) {
456                return false;
457            }
458            if crate::db::community::pending_invite_exists(&community_id).unwrap_or(false) {
459                return false;
460            }
461
462            // Supersession: a decline/leave tombstone suppresses any invite no newer than the
463            // decision (so the un-deletable 3304 can't re-nag, and a sibling's decline propagated via
464            // the synced list silences this device too). A STRICTLY-newer invite falls through and
465            // parks — a deliberate re-invite resurfaces. `wrapper_created_at` is outer-send seconds.
466            if crate::community::list::tombstone_suppresses(&community_id, wrapper_created_at) {
467                return false;
468            }
469
470            // Park for explicit consent — do NOT join, subscribe, or dial the bundle's
471            // relays here. The user accepts via the command layer.
472            let bundle_json = match invite.to_json() {
473                Ok(j) => j,
474                Err(e) => { log_warn!("[community] invite re-serialize failed: {}", e); return false; }
475            };
476            match crate::db::community::save_pending_invite(&community_id, &bundle_json, &inviter) {
477                Ok(true) => {
478                    handler.on_community_invite(&community_id);
479                    // Warm the community's first page in the background so a subsequent Accept opens
480                    // populated instead of paying the join sync. RAM-only + best-effort; promotion on
481                    // Join re-validates freshness. SessionGuard'd so a mid-flight swap is a no-op.
482                    let invite_warm = invite.clone();
483                    let bg = crate::state::SessionGuard::capture();
484                    tokio::spawn(async move {
485                        if !bg.is_valid() {
486                            return;
487                        }
488                        crate::community::service::preload_community(&invite_warm).await;
489                    });
490                }
491                Ok(false) => {} // raced — already parked
492                Err(e) => log_warn!("[community] invite park failed: {}", e),
493            }
494            false
495        }
496        PreparedEvent::CommunityInviteV2 { bundle_json, community_id, inviter, is_mine, wrapper_event_id_bytes, wrapper_created_at } => {
497            {
498                let mut cache = WRAPPER_ID_CACHE.lock().await;
499                cache.insert(wrapper_event_id_bytes);
500            }
501            let _ = crate::db::wrappers::save_processed_wrapper(&wrapper_event_id_bytes, wrapper_created_at, crate::db::wrappers::TRANSPORT_NIP17);
502
503            // Never park our own echoed invite.
504            if is_mine {
505                return false;
506            }
507            // Idempotency on the INNER community_id (already validated hex), not the
508            // attacker-controlled wrapper id: already held, or already parked → drop.
509            let held = match crate::simd::hex::hex_to_bytes_32_checked(&community_id) {
510                Some(b) => crate::community::CommunityId(b),
511                None => return false,
512            };
513            if crate::db::community::community_exists(&held).unwrap_or(false) {
514                return false;
515            }
516            if crate::db::community::pending_invite_exists(&community_id).unwrap_or(false) {
517                return false;
518            }
519            // Supersession: a decline/leave tombstone (protocol-agnostic, keyed on
520            // community_id) suppresses a re-wrapped invite no newer than the decision,
521            // so a declined/left community can't be re-nagged by a fresh ephemeral wrap.
522            if crate::community::list::tombstone_suppresses(&community_id, wrapper_created_at) {
523                return false;
524            }
525            // Park for explicit consent — do NOT join/subscribe here. Accept via the command layer.
526            match crate::db::community::save_pending_invite(&community_id, &bundle_json, &inviter) {
527                Ok(true) => handler.on_community_invite(&community_id),
528                Ok(false) => {} // raced — already parked
529                Err(e) => log_warn!("[community] v2 invite park failed: {}", e),
530            }
531            false
532        }
533        PreparedEvent::DedupSkip { wrapper_id_bytes, wrapper_created_at } => {
534            // Persist wrapper timestamp for negentropy backfill (skip no-op writes)
535            if wrapper_created_at > 0 {
536                let _ = crate::db::wrappers::update_wrapper_timestamp(&wrapper_id_bytes, wrapper_created_at);
537            }
538            false
539        }
540        PreparedEvent::ErrorSkip { wrapper_id_bytes, wrapper_created_at } => {
541            let _ = crate::db::wrappers::save_processed_wrapper(&wrapper_id_bytes, wrapper_created_at, crate::db::wrappers::TRANSPORT_NIP17);
542            false
543        }
544    }
545}
546
547/// Commit a DM text or file message (shared logic for both).
548async fn commit_dm_message(
549    mut msg: Message,
550    contact: &str,
551    _is_mine: bool,
552    is_new: bool,
553    wrapper_event_id: &str,
554    wrapper_event_id_bytes: [u8; 32],
555    handler: &dyn InboundEventHandler,
556    is_file: bool,
557) -> bool {
558    // Dedup: check if message already in DB
559    if let Ok(true) = crate::db::events::message_exists_in_db(&msg.id) {
560        // Already in DB — try to backfill wrapper_event_id
561        if let Ok(updated) = crate::db::events::update_wrapper_event_id(&msg.id, wrapper_event_id) {
562            if !updated {
563                let mut cache = WRAPPER_ID_CACHE.lock().await;
564                cache.insert(wrapper_event_id_bytes);
565            }
566        }
567        return false;
568    }
569
570    // Populate reply context
571    if !msg.replied_to.is_empty() {
572        let _ = crate::db::events::populate_reply_context(&mut msg).await;
573    }
574
575    // Add to STATE (+ clear typing indicator for file senders)
576    let added = {
577        let mut state = crate::state::STATE.lock().await;
578        let added = state.add_message_to_participant(contact, msg.clone());
579        if is_file && added {
580            state.update_typing_and_get_active(contact, contact, 0);
581        }
582        added
583    };
584
585    if added {
586        // Emit to frontend
587        crate::traits::emit_event("message_new", &serde_json::json!({
588            "message": &msg,
589            "chat_id": contact
590        }));
591
592        // Platform callback (notifications, badge, etc.)
593        if is_file {
594            handler.on_file_received(contact, &msg, is_new);
595        } else {
596            handler.on_dm_received(contact, &msg, is_new);
597        }
598
599        // Save to DB
600        let _ = crate::db::events::save_message(contact, &msg).await;
601    }
602
603    added
604}
605
606/// Commit a reaction event.
607async fn commit_reaction(
608    reaction: crate::types::Reaction,
609    contact: &str,
610    is_mine: bool,
611    wrapper_event_id: &str,
612    handler: &dyn InboundEventHandler,
613) -> bool {
614    // Add to STATE
615    let msg_for_emit = {
616        let mut state = crate::state::STATE.lock().await;
617        if let Some((chat_id, was_added)) = state.add_reaction_to_message(&reaction.reference_id, reaction.clone()) {
618            if was_added {
619                state.find_message(&reaction.reference_id)
620                    .map(|(_, msg)| (chat_id, msg))
621            } else { None }
622        } else { None }
623    };
624
625    if let Some((chat_id, msg)) = msg_for_emit {
626        crate::traits::emit_event("message_update", &serde_json::json!({
627            "old_id": &reaction.reference_id,
628            "message": &msg,
629            "chat_id": &chat_id
630        }));
631        let _ = crate::db::events::save_message(&chat_id, &msg).await;
632        handler.on_reaction_received(&chat_id, &msg);
633    }
634
635    // Always save reaction event with wrapper for dedup
636    if let Ok(chat_id) = crate::db::id_cache::get_chat_id_by_identifier(contact) {
637        let _ = crate::db::events::save_reaction_event(
638            &reaction, chat_id, None, is_mine, Some(wrapper_event_id.to_string())
639        ).await;
640    }
641
642    true
643}
644
645/// Commit a message edit.
646async fn commit_edit(
647    event: &mut crate::stored_event::StoredEvent,
648    contact: &str,
649    message_id: &str,
650    new_content: &str,
651    edited_at: u64,
652    emoji_tags: Vec<crate::types::EmojiTag>,
653    wrapper_event_id: &str,
654) -> bool {
655    if crate::db::events::event_exists(&event.id).unwrap_or(false) {
656        return false;
657    }
658    if let Ok(chat_id) = crate::db::id_cache::get_chat_id_by_identifier(contact) {
659        event.chat_id = chat_id;
660    }
661    event.wrapper_event_id = Some(wrapper_event_id.to_string());
662    let _ = crate::db::events::save_event(event).await;
663
664    let msg_for_emit = {
665        let mut state = crate::state::STATE.lock().await;
666        state.update_message_in_chat(contact, message_id, |msg| {
667            msg.apply_edit(new_content.to_string(), edited_at, emoji_tags.clone());
668        })
669    };
670    if let Some(msg) = msg_for_emit {
671        crate::traits::emit_event("message_update", &serde_json::json!({
672            "old_id": message_id,
673            "message": msg,
674            "chat_id": contact
675        }));
676    }
677    true
678}
679
680/// Commit a NIP-09 cooperative deletion request.
681///
682/// Authorization: only the original message's author can delete it
683/// (matches NIP-09's `event.pubkey == deletion.pubkey` rule applied to
684/// the inner rumor). For DMs, that means either the sender is `MY` (we
685/// deleted from another device) or the sender is the chat counterpart
686/// who originally sent that message. Anyone else's deletion is silently
687/// ignored.
688///
689/// On success: drops the message from in-memory STATE, removes the row
690/// from the events table, and emits `message_removed` so the frontend
691/// can fade the row out — same code path as failed-message cleanup.
692async fn commit_deletion(
693    target_event_id: &str,
694    contact: &str,
695    sender: &PublicKey,
696    handler: &dyn InboundEventHandler,
697) -> bool {
698    // Look up the original. If not present locally there's nothing to
699    // delete — the deletion notice arrived before the original (rare),
700    // or we never had it. Either way, no-op.
701    //
702    // KNOWN LIMITATION: late-binding deletions are not handled. If
703    // the deletion arrives BEFORE the original (cold sync, out-of-order
704    // relay delivery), we drop the deletion silently here, and when the
705    // original arrives later it shows up unhidden. A future enhancement
706    // would persist a `pending_deletions` table keyed by target id and
707    // apply queued deletions when the target is committed in
708    // commit_dm_message. The common case (deletion arrives after the
709    // original) works correctly today.
710    //
711    // For DM rumors the `npub` field is intentionally empty: the chat
712    // is between two parties, so the author is implicit from `mine`
713    // (me if true, chat counterpart if false). We derive the original
714    // author from that, since the rumor pubkey isn't stored.
715    let (mine, chat_id) = {
716        let state = crate::state::STATE.lock().await;
717        match state.find_message(target_event_id) {
718            Some((chat, msg)) => (msg.mine, chat.id.clone()),
719            None => return false,
720        }
721    };
722
723    // Authorization: deletion sender must match the original author.
724    // For DMs:
725    //   - mine == true:  original author == us (MY_PUBLIC_KEY).
726    //                    Authorized if the deletion sender is also us
727    //                    (i.e. came in via our own self-wrap from
728    //                    another device, multi-device sync).
729    //   - mine == false: original author == chat counterpart. Chat id
730    //                    for a DM is the counterpart's npub, so we
731    //                    parse it and compare against the deletion
732    //                    sender.
733    let authorized = if mine {
734        match crate::state::my_public_key() {
735            Some(my_pk) => *sender == my_pk,
736            None => false,
737        }
738    } else {
739        match nostr_sdk::PublicKey::from_bech32(&chat_id) {
740            Ok(counterpart) => sender == &counterpart,
741            Err(_) => false, // chat id wasn't an npub (shouldn't happen for DMs)
742        }
743    };
744    if !authorized {
745        eprintln!(
746            "[NIP-17 cooperative-delete] unauthorized: sender {} not the author of target {} (mine={}, chat={})",
747            sender.to_hex(), target_event_id, mine, chat_id
748        );
749        return false;
750    }
751
752    // Drop from in-memory state.
753    let removed = {
754        let mut state = crate::state::STATE.lock().await;
755        state.remove_message(target_event_id)
756    };
757    let removed_msg = match removed {
758        Some((_chat_id, msg)) => msg,
759        None => return false,
760    };
761
762    // Nuke any cached attachment files for this message — sender asked
763    // for the message to disappear, and a downloaded file the receiver
764    // never moved out of Vector's cache should go with it.
765    //
766    // Refcount filter: drop attachments still referenced by sibling
767    // messages so we don't yank a cached file from messages that
768    // still need it (Vector dedupes by SHA-256, so the same file
769    // can back multiple messages). User-managed paths are also left
770    // alone (canonicalize + starts_with check).
771    let unique = crate::deletion::filter_unreferenced_attachments(
772        target_event_id,
773        removed_msg.attachments,
774    ).await;
775    crate::deletion::delete_cached_attachment_files_pub(&unique);
776
777    // Drop from the events table.
778    if let Err(e) = crate::db::events::delete_event(target_event_id).await {
779        eprintln!(
780            "[NIP-17 cooperative-delete] DB delete failed for {}: {}",
781            target_event_id, e
782        );
783    }
784
785    // Tell the frontend to fade the row out. Reuses the existing
786    // message_removed event handled in main.js, so no new wiring.
787    crate::traits::emit_event(
788        "message_removed",
789        &serde_json::json!({
790            "id": target_event_id,
791            "chat_id": &chat_id,
792            "reason": "deleted-by-sender",
793        }),
794    );
795
796    handler.on_message_deleted(&chat_id, target_event_id);
797    let _ = contact;
798    true
799}
800
801/// Apply a cooperative reaction revocation (NIP-09 k=7) from the reaction's
802/// author. Removes the reaction from its parent message and drops the kind-7
803/// row, then live-refreshes the parent's chips. Returns false if we don't hold
804/// the reaction or the sender isn't its author.
805async fn commit_reaction_deletion(target_reaction_id: &str, sender: &PublicKey) -> bool {
806    let found = {
807        let state = crate::state::STATE.lock().await;
808        state.find_reaction(target_reaction_id)
809    };
810    let (chat_id, message_id, author_npub, _is_community) = match found {
811        Some(v) => v,
812        None => return false,
813    };
814
815    // Authorization: only the reaction's own author may revoke it.
816    let authorized = nostr_sdk::PublicKey::parse(&author_npub)
817        .map(|pk| pk == *sender)
818        .unwrap_or(false);
819    if !authorized {
820        eprintln!(
821            "[reaction-delete] unauthorized: sender {} is not the author of reaction {}",
822            sender.to_hex(), target_reaction_id
823        );
824        return false;
825    }
826
827    let updated = {
828        let mut state = crate::state::STATE.lock().await;
829        state.remove_reaction_from_message(&message_id, target_reaction_id)
830    };
831    let message = match updated {
832        Some((_cid, msg)) => msg,
833        None => return false,
834    };
835
836    // save_message is additive for reactions, so the kind-7 row must be
837    // dropped explicitly or it resurrects on reload.
838    if let Err(e) = crate::db::events::delete_event(target_reaction_id).await {
839        eprintln!("[reaction-delete] DB delete failed for {}: {}", target_reaction_id, e);
840    }
841
842    crate::traits::emit_event(
843        "message_update",
844        &serde_json::json!({
845            "old_id": &message_id,
846            "message": &message,
847            "chat_id": &chat_id,
848        }),
849    );
850    true
851}
852
853// ============================================================================
854// Convenience: single-call event processing
855// ============================================================================
856
857/// Process a single event through the full pipeline (prepare + commit).
858///
859/// Gets client and public key from globals. For callers that manage their
860/// own notification loop but want the full vector-core processing pipeline.
861pub async fn process_event(
862    event: Event,
863    is_new: bool,
864    handler: &dyn InboundEventHandler,
865) -> std::result::Result<bool, String> {
866    let client = crate::state::nostr_client()
867        .ok_or_else(|| "Nostr client not initialized".to_string())?;
868    let my_pk = crate::state::my_public_key()
869        .ok_or_else(|| "Public key not initialized".to_string())?;
870    let prepared = prepare_event(event, &client, my_pk).await;
871    Ok(commit_prepared_event(prepared, is_new, handler).await)
872}