Skip to main content

vector_core/
lib.rs

1//! Vector Core — the single source of truth for all Vector clients, SDKs, and interfaces.
2//!
3//! This crate contains ALL of Vector's business logic, fully decoupled from Tauri.
4//! It can be used by:
5//! - **src-tauri**: The Tauri desktop/mobile app (thin command shell)
6//! - **vector-cli**: Command-line interface
7//! - **Vector SDK**: Bot and client libraries
8//! - Any future interface (web, embedded, etc.)
9//!
10//! # Architecture
11//!
12//! ```text
13//! ┌─────────────────────────────────────────────┐
14//! │              vector-core                     │
15//! │                                              │
16//! │  types ─ compact ─ state ─ db ─ crypto       │
17//! │  chat ─ profile ─ net ─ hex                  │
18//! │                                              │
19//! │  traits::EventEmitter (UI abstraction)       │
20//! │  VectorCore (high-level API)                 │
21//! └─────────────────────────────────────────────┘
22//!        ▲              ▲              ▲
23//!   src-tauri       vector-cli     Vector SDK
24//! (AppHandle)      (terminal)     (callbacks)
25//! ```
26
27// === Logging (must be first — #[macro_export] macros used by all modules) ===
28#[macro_use]
29mod macros;
30
31// === Foundation ===
32pub mod error;
33pub mod traits;
34
35// Nostr SDK trait imports needed for bech32 operations
36use nostr_sdk::prelude::ToBech32;
37
38// === Core Types ===
39pub mod types;
40pub mod profile;
41pub mod chat;
42pub mod compact;
43
44// === State ===
45pub mod state;
46
47// === Debug Stats ===
48#[cfg(debug_assertions)]
49pub mod stats;
50
51// === Crypto ===
52pub mod crypto;
53
54// === Signer (polymorphic: local vault vs. NIP-46 remote bunker) ===
55pub mod signer;
56
57// === Database ===
58pub mod db;
59
60// === Network ===
61pub mod net;
62pub mod negentropy;
63pub mod blossom;
64pub mod blossom_servers;
65pub mod blossom_capabilities;
66pub mod inbox_relays;
67pub mod emoji_packs;
68pub mod emoji_usage;
69pub mod badges;
70pub mod webxdc;
71#[cfg(feature = "tor")]
72pub mod tor;
73
74/// Build a `nostr_sdk::ClientOptions` with the embedded-Tor SOCKS proxy
75/// applied if (and only if) the `tor` feature is on AND `tor::TorService` is
76/// currently active. When Tor is off, returns the default options unchanged.
77///
78/// Note: nostr-sdk's `proxy()` lives on `Connection`, not `ClientOptions`
79/// directly — we build a `Connection` with the proxy mode and pass it via
80/// `ClientOptions::connection(...)`. The `connection()` method itself is
81/// `#[cfg(not(target_arch = "wasm32"))]`, but Vector targets are all native.
82///
83/// Callers should use this rather than `ClientOptions::new()` directly so the
84/// Tor toggle automatically covers their relay traffic.
85pub fn nostr_client_options() -> nostr_sdk::ClientOptions {
86    // NIP-42: authenticate to relays that challenge, using the account signer. Many
87    // Concord/Armada communities live on AUTH-gating relays (Ditto's default gates
88    // kind-1059), where an unauthenticated client silently reads back ZERO events —
89    // so a join's control-plane verify fails closed and every community fetch comes
90    // up empty. Auto-auth unlocks those reads; a relay that doesn't challenge is
91    // unaffected.
92    let opts = nostr_sdk::ClientOptions::new().automatic_authentication(true);
93    #[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
94    {
95        match tor::transport_state() {
96            tor::TorTransportState::Active(addr) => {
97                return opts.connection(nostr_sdk::client::Connection::new().proxy(addr));
98            }
99            tor::TorTransportState::RequiredButInactive => {
100                // Tor failsafe: route to a blackhole so the relay socket can't
101                // accidentally come up direct while Tor is mid-bootstrap.
102                return opts.connection(
103                    nostr_sdk::client::Connection::new().proxy(tor::blackhole_proxy_addr()),
104                );
105            }
106            tor::TorTransportState::Disabled => {}
107        }
108    }
109    opts
110}
111
112/// Augment a `RelayOptions` with the Tor connection mode when active. Used
113/// by every site that adds a relay to the pool — default relays at boot,
114/// custom user relays, community relays, NIP-17 inbox relays — so they all
115/// come up through Tor when the toggle is on.
116///
117/// Without this, `RelayOptions::new()` (or the per-mode helper) defaults to
118/// `ConnectionMode::Direct`, and relays added at boot would stay direct even
119/// after Tor is bootstrapped — `switch_relay_transport()` covers existing
120/// relays on toggle, but not freshly-added ones.
121pub fn tor_aware_relay_options(opts: nostr_sdk::RelayOptions) -> nostr_sdk::RelayOptions {
122    #[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
123    {
124        match tor::transport_state() {
125            tor::TorTransportState::Active(addr) => {
126                return opts.connection_mode(nostr_sdk::pool::ConnectionMode::proxy(addr));
127            }
128            tor::TorTransportState::RequiredButInactive => {
129                // Tor failsafe: pin to blackhole so this relay can never come
130                // up direct while Tor isn't running.
131                return opts.connection_mode(
132                    nostr_sdk::pool::ConnectionMode::proxy(tor::blackhole_proxy_addr()),
133                );
134            }
135            tor::TorTransportState::Disabled => {}
136        }
137    }
138    opts
139}
140
141/// Relay options for a Community / "external" relay: the GOSSIP flag (+ PING for a 24/7 keepalive
142/// connection). GOSSIP is read/write-capable when TARGETED — `can_read()` is `READ|GOSSIP|DISCOVERY`
143/// and `can_write()` is `WRITE|GOSSIP`, so per-relay checks pass for `fetch_events_from` /
144/// `send_event_to` / `subscribe_to`. But pool-wide ops select READ-only / WRITE-only relays, so the
145/// DM/giftwrap subscription (`subscribe(None)`) and the user's outbox (`send_event`) skip GOSSIP
146/// relays — the user's own traffic never touches relays they don't own. (A bare PING-only relay can
147/// NOT be used: `can_write()`/`can_read()` are false → the relay layer returns WriteDisabled /
148/// ReadDisabled.) An overlap relay that's ALSO a user relay keeps its existing READ+WRITE flags —
149/// `add_relay` is a no-op (`Ok(false)`) for a url already pooled, reusing the one existing connection.
150pub fn community_relay_options() -> nostr_sdk::RelayOptions {
151    use nostr_sdk::RelayServiceFlags;
152    tor_aware_relay_options(
153        nostr_sdk::RelayOptions::new().flags(RelayServiceFlags::GOSSIP | RelayServiceFlags::PING),
154    )
155}
156
157/// Relay options for a Discovery Relay (see `state::DISCOVERY_RELAYS`): the same
158/// GOSSIP|PING targeted-only isolation as Community relays — reachable via
159/// `fetch_events_from` / `send_event_to`, invisible to pool-wide DM/profile ops.
160/// An overlap with a user relay keeps the user's READ+WRITE flags (`add_relay`
161/// no-ops on an already-pooled url).
162pub fn discovery_relay_options() -> nostr_sdk::RelayOptions {
163    community_relay_options()
164}
165
166// === Event Storage ===
167pub mod stored_event;
168
169// === Rumor Processing ===
170pub mod rumor;
171
172// === Messaging ===
173pub mod sending;
174
175// === Per-DM Wallpapers ===
176pub mod wallpaper;
177
178// === Message Deletion (NIP-09 against retained gift-wraps) ===
179pub mod deletion;
180
181// === SIMD Operations ===
182pub mod simd;
183
184// === Community protocol (GROUP_PROTOCOL.md) ===
185pub mod community;
186
187// === Event Handler ===
188pub mod event_handler;
189
190// === Re-exports for convenience ===
191pub use types::{Message, Attachment, Reaction, EditEntry, ImageMetadata, SiteMetadata, LoginResult, AttachmentFile, mention, extract_mentions};
192pub use profile::{Profile, ProfileFlags, SlimProfile, Status};
193pub use chat::{Chat, ChatType, ChatMetadata, SerializableChat};
194pub use compact::{CompactMessage, CompactMessageVec, NpubInterner};
195pub use state::{
196    ChatState, NOSTR_CLIENT, MY_SECRET_KEY, MY_PUBLIC_KEY, STATE, ENCRYPTION_KEY,
197    nostr_client, my_public_key, has_active_session,
198    set_nostr_client, set_my_public_key,
199    take_nostr_client, clear_my_public_key,
200    set_pending_bunker_setup, pending_bunker_setup, clear_pending_bunker_setup,
201};
202pub use crypto::{GuardedKey, GuardedSigner};
203pub use signer::{
204    SignerKind, signer_kind, set_signer_kind, is_bunker,
205    BUNKER_SIGNER, bunker_signer, set_bunker_signer, take_bunker_signer,
206    build_bunker_signer, prewarm_bunker, drain_bunker_state,
207    parse_bunker_remote_pubkey, parse_bunker_relays,
208    BunkerConnectionState, bunker_state, set_bunker_state,
209    VectorAuthUrlHandler, attempt_bunker_login, WatchedBunkerSigner,
210    vector_metadata, build_nostrconnect_uri, build_nostrconnect_session,
211    VECTOR_APP_NAME, VECTOR_APP_URL, VECTOR_APP_ICON,
212};
213pub use error::{VectorError, Result};
214pub use traits::{EventEmitter, NoOpEmitter, set_event_emitter, emit_event};
215pub use db::{set_app_data_dir, get_app_data_dir};
216pub use sending::{SendCallback, NoOpSendCallback, SendConfig, SendResult};
217pub use deletion::{delete_own_dm, DeleteOutcome};
218pub use stored_event::{StoredEvent, StoredEventBuilder, SystemEventType};
219pub use rumor::{RumorEvent, RumorContext, ConversationType, RumorProcessingResult, process_rumor};
220pub use profile::{SyncPriority, ProfileSyncHandler, NoOpProfileSyncHandler};
221pub use event_handler::{InboundEventHandler, NoOpEventHandler, PreparedEvent, process_event};
222
223use std::path::PathBuf;
224use std::sync::Arc;
225
226// ============================================================================
227// VectorCore — High-level API
228// ============================================================================
229
230/// Configuration for initializing VectorCore.
231pub struct CoreConfig {
232    /// Path to the app data directory (e.g., ~/.local/share/io.vectorapp/data/)
233    pub data_dir: PathBuf,
234    /// Optional event emitter for UI integration
235    pub event_emitter: Option<Box<dyn EventEmitter>>,
236}
237
238/// The main entry point for Vector Core.
239///
240/// Provides a high-level API for all Vector operations. Internally uses
241/// global state (same pattern as the Tauri backend) for compatibility.
242///
243/// ```no_run
244/// use vector_core::{VectorCore, CoreConfig};
245/// use std::path::PathBuf;
246///
247/// # async fn example() -> vector_core::Result<()> {
248/// let core = VectorCore::init(CoreConfig {
249///     data_dir: PathBuf::from("/tmp/vector-data"),
250///     event_emitter: None,
251/// })?;
252///
253/// // Login with nsec
254/// let result = core.login("nsec1...", None).await?;
255/// println!("Logged in as {}", result.npub);
256/// # Ok(())
257/// # }
258/// ```
259#[derive(Clone, Copy)]
260pub struct VectorCore;
261
262impl VectorCore {
263    /// Initialize Vector Core with the given configuration.
264    pub fn init(config: CoreConfig) -> Result<Self> {
265        // Set data directory
266        db::set_app_data_dir(config.data_dir);
267
268        // Set event emitter (or no-op)
269        if let Some(emitter) = config.event_emitter {
270            traits::set_event_emitter(emitter);
271        }
272
273        // Install rustls ring provider
274        let _ = rustls::crypto::ring::default_provider().install_default();
275
276        Ok(VectorCore)
277    }
278
279    /// Get all available accounts.
280    pub fn accounts(&self) -> Result<Vec<String>> {
281        db::get_accounts().map_err(VectorError::from)
282    }
283
284    /// Login with an nsec key or mnemonic seed phrase.
285    pub async fn login(&self, key: &str, password: Option<&str>) -> Result<LoginResult> {
286        use nostr_sdk::prelude::*;
287
288        // Parse the key
289        let keys = if key.starts_with("nsec1") {
290            let secret = SecretKey::from_bech32(key)
291                .map_err(|e| VectorError::Nostr(format!("Invalid nsec: {}", e)))?;
292            Keys::new(secret)
293        } else {
294            // Treat as mnemonic (NIP-06: derive from BIP-39 seed)
295            Keys::from_mnemonic(key, None)
296                .map_err(|e| VectorError::Nostr(format!("Key derivation failed: {}", e)))?
297        };
298
299        let public_key = keys.public_key();
300        let npub = public_key.to_bech32()
301            .map_err(|e| VectorError::Nostr(format!("Failed to encode npub: {}", e)))?;
302
303        // Store in GuardedKey vault (pass other vaults to protect during decoy writes)
304        let secret_bytes = keys.secret_key().to_secret_bytes();
305        state::MY_SECRET_KEY.set(secret_bytes, &[&state::ENCRYPTION_KEY]);
306        state::set_my_public_key(public_key);
307
308        // Initialize database for this account
309        db::set_current_account(npub.clone())?;
310        db::init_database(&npub)?;
311
312        // Store nsec for encryption setup
313        {
314            let nsec = keys.secret_key().to_bech32()
315                .map_err(|e| VectorError::Nostr(format!("Failed to encode nsec: {}", e)))?;
316            *state::PENDING_NSEC.lock().unwrap() = Some(nsec.clone());
317
318            // NEVER clobber an existing encrypted key with the plaintext nsec. An account with encryption
319            // enabled keeps its key encrypted-at-rest (PIN-derived); overwriting it with the raw nsec — e.g.
320            // a no-password headless/diagnostic login (the concord CLI) — would leave the GUI deriving the
321            // right key from the correct PIN but trying to decrypt a value that's no longer ciphertext, i.e.
322            // "incorrect pin" with the real key effectively lost. MY_SECRET_KEY is already set in-memory above,
323            // so login works regardless; only persist the raw key when there's no encrypted key to protect.
324            let existing_encrypted = db::get_pkey().ok().flatten().is_some_and(|v| !v.starts_with("nsec1"));
325            if !(state::resolve_encryption_enabled_from_db() && existing_encrypted) {
326                db::set_pkey(&nsec)?;
327            }
328        }
329
330        // Use the canonical resolver so this high-level API agrees with
331        // crypto::is_encryption_enabled and the Android bg-sync probe.
332        let has_encryption = state::resolve_encryption_enabled_from_db();
333
334        if has_encryption {
335            if let Some(pwd) = password {
336                let key = crate::crypto::hash_pass(pwd).await;
337                state::ENCRYPTION_KEY.set(key, &[&state::MY_SECRET_KEY]);
338            }
339        }
340        // Seed the atomic unconditionally — `is_encryption_enabled_fast()`
341        // must agree with the DB regardless of branch.
342        state::init_encryption_enabled();
343
344        // Build Nostr client — tor-aware options so a headless consumer with
345        // the Tor pref ON proxies (or blackholes) instead of dialing direct.
346        let client = ClientBuilder::new()
347            .signer(keys)
348            .opts(nostr_client_options())
349            // Relay health monitor — powers the reconnect-driven catch-up in `listen()`.
350            .monitor(Monitor::new(1024))
351            .build();
352
353        // Add trusted relays
354        for relay in state::TRUSTED_RELAYS {
355            let opts = tor_aware_relay_options(nostr_sdk::RelayOptions::default());
356            client.pool().add_relay(*relay, opts).await.ok();
357        }
358
359        // Connect
360        client.connect().await;
361
362        let _ = { state::set_nostr_client(client); Ok::<(), ()>(()) };
363
364        Ok(LoginResult { npub, has_encryption })
365    }
366
367    /// Generate a fresh random account secret key (bech32 nsec). Lets a headless client spin up a
368    /// brand-new identity (`add_account` with no key) without depending on nostr-sdk directly.
369    pub fn generate_nsec(&self) -> Result<String> {
370        use nostr_sdk::prelude::*;
371        Keys::generate().secret_key().to_bech32()
372            .map_err(|e| VectorError::Nostr(format!("Failed to encode nsec: {}", e)))
373    }
374
375    /// Send a NIP-17 gift-wrapped text DM using the full pipeline. Retries a
376    /// transient publish miss (headless preset, 3 attempts) so an SDK/CLI bot rides
377    /// out a relay blip instead of silently dropping the message on the first miss;
378    /// `self_send: false` keeps it a plain send (no inbox self-copy).
379    pub async fn send_dm(&self, to_npub: &str, content: &str) -> Result<sending::SendResult> {
380        let config = SendConfig { self_send: false, ..SendConfig::headless() };
381        sending::send_dm(to_npub, content, None, &config, Arc::new(NoOpSendCallback)).await
382            .map_err(|e| VectorError::Other(e))
383    }
384
385    /// Send a DM as a threaded reply to `replied_to` (an existing message's event id).
386    pub async fn send_dm_reply(&self, to_npub: &str, replied_to: &str, content: &str) -> Result<sending::SendResult> {
387        let config = SendConfig { self_send: false, ..SendConfig::headless() };
388        sending::send_dm(to_npub, content, Some(replied_to), &config, Arc::new(NoOpSendCallback)).await
389            .map_err(|e| VectorError::Other(e))
390    }
391
392    /// Download a received attachment and decrypt it to plaintext bytes. Fetches the encrypted blob
393    /// from its Blossom URL (SSRF/Tor-aware client, size-capped) and AES-decrypts with the
394    /// attachment's embedded key + nonce.
395    pub async fn download_attachment(&self, attachment: &Attachment) -> Result<Vec<u8>> {
396        use futures_util::StreamExt;
397        const MAX_DOWNLOAD: usize = 256 * 1024 * 1024;
398        if attachment.url.is_empty() {
399            return Err(VectorError::Other("attachment has no URL".into()));
400        }
401        // SSRF guard: the URL is attacker-controlled (off an inbound message). build_http_client only
402        // validates redirect HOPS, not the initial request — so validate it here (matches the native
403        // download path). With Tor off this is the only egress guard.
404        crate::net::validate_url_not_private(&attachment.url)
405            .map_err(|e| VectorError::Other(e.to_string()))?;
406        let client = crate::net::build_http_client(std::time::Duration::from_secs(120)).map_err(VectorError::Other)?;
407        let resp = client.get(&attachment.url).send().await
408            .map_err(|e| VectorError::Other(format!("download: {e}")))?;
409        if !resp.status().is_success() {
410            return Err(VectorError::Other(format!("download failed: HTTP {}", resp.status())));
411        }
412        // Stream with a cap so a hostile/oversized blob can't OOM the process.
413        let mut encrypted: Vec<u8> = Vec::with_capacity(
414            resp.content_length().map(|l| (l as usize).min(MAX_DOWNLOAD)).unwrap_or(64 * 1024),
415        );
416        let mut stream = resp.bytes_stream();
417        while let Some(chunk) = stream.next().await {
418            let chunk = chunk.map_err(|e| VectorError::Other(format!("read body: {e}")))?;
419            if encrypted.len() + chunk.len() > MAX_DOWNLOAD {
420                return Err(VectorError::Other("attachment exceeds 256 MiB cap".into()));
421            }
422            encrypted.extend_from_slice(&chunk);
423        }
424        crate::crypto::decrypt_data(&encrypted, &attachment.key, &attachment.nonce).map_err(VectorError::Other)
425    }
426
427    /// Send a NIP-17 gift-wrapped file attachment DM.
428    pub async fn send_file(&self, to_npub: &str, file_path: &str) -> Result<sending::SendResult> {
429        let path = std::path::Path::new(file_path);
430        let bytes = std::fs::read(path)
431            .map_err(|e| VectorError::Io(e))?;
432        let filename = path.file_name()
433            .and_then(|n| n.to_str())
434            .unwrap_or("file");
435        let extension = path.extension()
436            .and_then(|e| e.to_str())
437            .unwrap_or("bin");
438
439        sending::send_file_dm(
440            to_npub,
441            std::sync::Arc::new(bytes),
442            filename,
443            extension,
444            None,
445            &SendConfig::default(),
446            Arc::new(NoOpSendCallback),
447        ).await.map_err(|e| VectorError::Other(e))
448    }
449
450    /// Send a NIP-25 reaction to a DM message. `emoji_url` carries the NIP-30
451    /// image URL when reacting with a custom-pack emoji (content stays
452    /// `:shortcode:`). Returns the reaction's rumor id. Local echo + persistence
453    /// are best-effort — the gift-wrap send is the source of truth.
454    pub async fn send_reaction(
455        &self,
456        to_npub: &str,
457        reference_id: &str,
458        emoji: &str,
459        emoji_url: Option<&str>,
460    ) -> Result<String> {
461        use nostr_sdk::prelude::*;
462
463        let client = state::nostr_client().ok_or(VectorError::Other("Not connected".into()))?;
464        let my_public_key = state::my_public_key().ok_or(VectorError::Other("Not logged in".into()))?;
465
466        let reference_event = EventId::from_hex(reference_id)
467            .map_err(|e| VectorError::Nostr(e.to_string()))?;
468        let receiver_pubkey = PublicKey::from_bech32(to_npub)
469            .map_err(|e| VectorError::Nostr(e.to_string()))?;
470
471        // NIP-30 custom-emoji tag — only when content is `:shortcode:` and a URL is present.
472        let custom_emoji_tag = emoji_url.and_then(|url| {
473            if !emoji.starts_with(':') || !emoji.ends_with(':') || emoji.len() < 3 || url.is_empty() {
474                return None;
475            }
476            let shortcode = &emoji[1..emoji.len() - 1];
477            if shortcode.is_empty() { return None; }
478            Some(Tag::custom(TagKind::custom("emoji"), [shortcode.to_string(), url.to_string()]))
479        });
480
481        let reaction_target = nostr_sdk::nips::nip25::ReactionTarget {
482            event_id: reference_event,
483            public_key: receiver_pubkey,
484            coordinate: None,
485            kind: Some(Kind::PrivateDirectMessage),
486            relay_hint: None,
487        };
488        let mut builder = EventBuilder::reaction(reaction_target, emoji);
489        if let Some(tag) = custom_emoji_tag {
490            builder = builder.tag(tag);
491        }
492        let rumor = builder.build(my_public_key);
493        let inner_rumor_id = rumor.id;
494        let rumor_id = inner_rumor_id.ok_or(VectorError::Other("Failed to get rumor ID".into()))?.to_hex();
495
496        // Retain the recipient wrap's ephemeral key + targeted relays so the
497        // reaction can later be revoked with a NIP-09 relay nuke (mirrors the
498        // DM message send path). Without retention the reaction is undeletable.
499        let outcome = inbox_relays::send_gift_wrap_retained(&client, &receiver_pubkey, rumor.clone(), [])
500            .await.map_err(VectorError::Other)?;
501        if !outcome.output.success.is_empty() {
502            if let Some(rid) = inner_rumor_id {
503                if let Err(e) = db::nip17_keys::store_wrap_key(
504                    &outcome.wrap_event_id, &rid, &receiver_pubkey,
505                    db::nip17_keys::WrapRole::Recipient,
506                    &outcome.wrap_secret, &outcome.targeted_relays,
507                ) {
508                    crate::log_warn!("[Reaction] failed to persist wrap key: {}", e);
509                }
510            }
511        }
512
513        // Self-wrap for multi-device recovery + retain its key too, so another
514        // device (or this one) can later revoke. Bail on account swap.
515        let self_wrap_client = client.clone();
516        let self_wrap_session = state::SessionGuard::capture();
517        tokio::spawn(async move {
518            if !self_wrap_session.is_valid() { return; }
519            if let Ok(self_outcome) = inbox_relays::send_gift_wrap_retained(
520                &self_wrap_client, &my_public_key, rumor, [],
521            ).await {
522                if !self_wrap_session.is_valid() { return; }
523                if !self_outcome.output.success.is_empty() {
524                    if let Some(rid) = inner_rumor_id {
525                        let _ = db::nip17_keys::store_wrap_key(
526                            &self_outcome.wrap_event_id, &rid, &my_public_key,
527                            db::nip17_keys::WrapRole::SelfSend,
528                            &self_outcome.wrap_secret, &self_outcome.targeted_relays,
529                        );
530                    }
531                }
532            }
533        });
534
535        // Best-effort optimistic local echo + persistence.
536        let reaction = Reaction {
537            id: rumor_id.clone(),
538            reference_id: reference_id.to_string(),
539            author_id: my_public_key.to_bech32().unwrap_or_else(|_| my_public_key.to_hex()),
540            emoji: emoji.to_string(),
541            emoji_url: emoji_url.map(|s| s.to_string()),
542        };
543        let msg_for_save = {
544            let mut st = state::STATE.lock().await;
545            match st.add_reaction_to_message(reference_id, reaction) {
546                Some((cid, true)) => st.find_message(reference_id).map(|(_, m)| (cid, m)),
547                _ => None,
548            }
549        };
550        if let Some((cid, msg)) = msg_for_save {
551            let _ = db::events::save_message(&cid, &msg).await;
552            traits::emit_event_json("message_update", serde_json::json!({
553                "old_id": reference_id, "message": &msg, "chat_id": &cid
554            }));
555        }
556
557        Ok(rumor_id)
558    }
559
560    /// Send an ephemeral typing indicator to a DM recipient. Fire-and-forget
561    /// with a 30-second NIP-40 expiry so relays purge it quickly.
562    pub async fn send_typing(&self, to_npub: &str) -> Result<()> {
563        use nostr_sdk::prelude::*;
564
565        let client = state::nostr_client().ok_or(VectorError::Other("Not connected".into()))?;
566        let my_public_key = state::my_public_key().ok_or(VectorError::Other("Not logged in".into()))?;
567        let pubkey = PublicKey::from_bech32(to_npub).map_err(|e| VectorError::Nostr(e.to_string()))?;
568
569        let expiry = Timestamp::from_secs(Timestamp::now().as_secs() + 30);
570        let rumor = EventBuilder::new(Kind::ApplicationSpecificData, "typing")
571            .tag(Tag::public_key(pubkey))
572            .tag(Tag::custom(TagKind::d(), vec!["vector"]))
573            .tag(Tag::expiration(expiry))
574            .build(my_public_key);
575
576        client.gift_wrap_to(
577            state::active_trusted_relays().await,
578            &pubkey,
579            rumor,
580            [Tag::expiration(expiry)],
581        ).await.map_err(|e| VectorError::Nostr(e.to_string()))?;
582        Ok(())
583    }
584
585    /// Edit a DM you previously sent (kind-16 edit) with an optimistic local
586    /// echo. Returns the edit event id. Persistence is best-effort and only
587    /// happens when the chat already exists locally.
588    pub async fn edit_dm(&self, to_npub: &str, message_id: &str, new_content: &str) -> Result<String> {
589        use nostr_sdk::prelude::*;
590
591        let client = state::nostr_client().ok_or(VectorError::Other("Not connected".into()))?;
592        let my_public_key = state::my_public_key().ok_or(VectorError::Other("Not logged in".into()))?;
593        let my_npub = my_public_key.to_bech32().map_err(|e| VectorError::Nostr(e.to_string()))?;
594        let receiver_pubkey = PublicKey::from_bech32(to_npub).map_err(|e| VectorError::Nostr(e.to_string()))?;
595        let reference_event = EventId::from_hex(message_id).map_err(|e| VectorError::Nostr(e.to_string()))?;
596
597        // NIP-30: resolve `:shortcode:` so the edit carries emoji image tags.
598        let emoji_tags = emoji_packs::resolve_outbound_emoji_tags(new_content);
599
600        let mut builder = EventBuilder::new(
601            Kind::from_u16(stored_event::event_kind::MESSAGE_EDIT),
602            new_content,
603        ).tag(Tag::event(reference_event));
604        for et in &emoji_tags {
605            builder = builder.tag(Tag::custom(
606                TagKind::custom("emoji"),
607                [et.shortcode.clone(), et.url.clone()],
608            ));
609        }
610        let rumor = builder.build(my_public_key);
611        let edit_id = rumor.id.ok_or(VectorError::Other("Failed to get edit rumor ID".into()))?.to_hex();
612        let edit_ts_ms = rumor.created_at.as_secs() * 1000;
613
614        // Optimistic local echo + best-effort persistence.
615        let msg_for_emit = {
616            let mut st = state::STATE.lock().await;
617            st.update_message_in_chat(to_npub, message_id, |msg| {
618                msg.apply_edit(new_content.to_string(), edit_ts_ms, emoji_tags.clone());
619                msg.preview_metadata = None;
620            })
621        };
622        if let Some(msg) = msg_for_emit {
623            traits::emit_event_json("message_update", serde_json::json!({
624                "old_id": message_id, "message": &msg, "chat_id": to_npub
625            }));
626            if let Ok(db_chat_id) = db::id_cache::get_chat_id_by_identifier(to_npub) {
627                let _ = db::events::save_edit_event(
628                    &edit_id, message_id, new_content, &emoji_tags, db_chat_id, None, &my_npub,
629                ).await;
630            }
631        }
632
633        inbox_relays::send_gift_wrap(&client, &receiver_pubkey, rumor.clone(), [])
634            .await.map_err(VectorError::Other)?;
635
636        let self_wrap_client = client.clone();
637        let self_wrap_session = state::SessionGuard::capture();
638        tokio::spawn(async move {
639            if !self_wrap_session.is_valid() { return; }
640            let _ = self_wrap_client.gift_wrap(&my_public_key, rumor, []).await;
641        });
642
643        Ok(edit_id)
644    }
645
646    /// Delete a DM you sent (NIP-09 over the retained gift-wrap keys).
647    pub async fn delete_dm(&self, message_id: &str) -> Result<deletion::DeleteOutcome> {
648        use nostr_sdk::prelude::*;
649        let rumor_id = EventId::from_hex(message_id).map_err(|e| VectorError::Nostr(e.to_string()))?;
650        deletion::delete_own_dm(&rumor_id).await.map_err(VectorError::Other)
651    }
652
653    /// Get chats from the in-memory state.
654    pub async fn get_chats(&self) -> Vec<SerializableChat> {
655        let state = state::STATE.lock().await;
656        state.chats.iter()
657            .map(|c| c.to_serializable_with_last_n(1, &state.interner))
658            .collect()
659    }
660
661    /// Get messages for a chat (paginated).
662    pub async fn get_messages(&self, chat_id: &str, limit: usize, offset: usize) -> Vec<Message> {
663        let state = state::STATE.lock().await;
664        if let Some(chat) = state.get_chat(chat_id) {
665            let msgs = chat.get_all_messages(&state.interner);
666            let start = offset.min(msgs.len());
667            let end = (offset + limit).min(msgs.len());
668            msgs[start..end].to_vec()
669        } else {
670            Vec::new()
671        }
672    }
673
674    /// Get a profile by npub.
675    pub async fn get_profile(&self, npub: &str) -> Option<SlimProfile> {
676        let state = state::STATE.lock().await;
677        state.get_profile(npub)
678            .map(|p| SlimProfile::from_profile(p, &state.interner))
679    }
680
681    /// Fetch a profile's metadata and status from relays.
682    pub async fn load_profile(&self, npub: &str) -> bool {
683        profile::sync::load_profile(npub.to_string(), &NoOpProfileSyncHandler).await
684    }
685
686    /// Update the current user's profile metadata and broadcast to relays.
687    pub async fn update_profile(&self, name: &str, avatar: &str, banner: &str, about: &str) -> bool {
688        profile::sync::update_profile(
689            name.to_string(), avatar.to_string(), banner.to_string(), about.to_string(),
690            &NoOpProfileSyncHandler,
691        ).await
692    }
693
694    /// Like [`update_profile`](Self::update_profile) but marks the profile as a bot (`bot: true` in
695    /// the metadata). The SDK uses this for every bot; build human clients on `update_profile`.
696    pub async fn update_bot_profile(&self, name: &str, avatar: &str, banner: &str, about: &str) -> bool {
697        profile::sync::update_bot_profile(
698            name.to_string(), avatar.to_string(), banner.to_string(), about.to_string(),
699            &NoOpProfileSyncHandler,
700        ).await
701    }
702
703    /// Update the current user's status and broadcast to relays.
704    pub async fn update_status(&self, status: &str) -> bool {
705        profile::sync::update_status(status.to_string()).await
706    }
707
708    /// Upload an image file to Blossom **unencrypted** and return its public URL — for avatars,
709    /// banners, and other images other clients must fetch directly. (The opposite of
710    /// [`send_file`](Self::send_file)'s encrypted attachments.) Pass the URL to [`update_profile`].
711    ///
712    /// [`update_profile`]: Self::update_profile
713    pub async fn upload_public_image(&self, file_path: &str) -> Result<String> {
714        let path = std::path::Path::new(file_path);
715        let bytes = std::fs::read(path).map_err(VectorError::Io)?;
716        if bytes.is_empty() {
717            return Err(VectorError::Other("Empty image file".into()));
718        }
719        let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("bin").to_lowercase();
720        let mime = crate::crypto::mime_from_extension(&extension);
721        let client = state::nostr_client().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
722        let signer = client
723            .signer()
724            .await
725            .map_err(|e| VectorError::Other(format!("Signer unavailable: {e}")))?;
726        let servers = crate::blossom_servers::compute_enabled_servers();
727        if servers.is_empty() {
728            return Err(VectorError::Other("No Blossom servers configured".into()));
729        }
730        crate::blossom::upload_blob_with_failover(signer, servers, std::sync::Arc::new(bytes), Some(mime))
731            .await
732            .map_err(VectorError::Other)
733    }
734
735    /// Block a user by npub.
736    pub async fn block_user(&self, npub: &str) -> bool {
737        profile::sync::block_user(npub.to_string(), &NoOpProfileSyncHandler).await
738    }
739
740    /// Unblock a user by npub.
741    pub async fn unblock_user(&self, npub: &str) -> bool {
742        profile::sync::unblock_user(npub.to_string(), &NoOpProfileSyncHandler).await
743    }
744
745    /// Set a nickname for a profile.
746    pub async fn set_nickname(&self, npub: &str, nickname: &str) -> bool {
747        profile::sync::set_nickname(npub.to_string(), nickname.to_string(), &NoOpProfileSyncHandler).await
748    }
749
750    /// Get all blocked profiles.
751    pub async fn get_blocked_users(&self) -> Vec<SlimProfile> {
752        profile::sync::get_blocked_users().await
753    }
754
755    /// Queue a profile for background sync.
756    pub fn queue_profile_sync(&self, npub: &str, priority: SyncPriority) {
757        profile::sync::queue_profile_sync(npub.to_string(), priority, false);
758    }
759
760    /// Get the current user's npub.
761    pub fn my_npub(&self) -> Option<String> {
762        state::my_public_key()
763            .and_then(|pk| ToBech32::to_bech32(&pk).ok())
764    }
765
766    // === Communities (headless) ===
767    // The GUI's Tauri commands carry optimistic-echo + emit machinery a headless client
768    // doesn't need; these are the lean equivalents over the same `community::service` layer,
769    // so a CLI / agent can join, read, post, and sync a Community.
770
771    /// List every Community held locally (owned or joined), each with its channels.
772    pub async fn list_communities(&self) -> Vec<serde_json::Value> {
773        use crate::community::ConcordProtocol;
774        let ids = crate::db::community::list_community_ids().unwrap_or_default();
775        let mut out = Vec::new();
776        for id in ids {
777            // Dual-stack: dispatch each held community by its stored protocol.
778            match crate::db::community::community_protocol(&id).ok().flatten() {
779                Some(ConcordProtocol::V2) => {
780                    if let Ok(Some(c)) = crate::db::community::load_community_v2(&id) {
781                        let me = state::my_public_key();
782                        let is_owner = me.is_some_and(|m| c.owner().is_ok_and(|o| o == m));
783                        out.push(serde_json::json!({
784                            "community_id": crate::simd::hex::bytes_to_hex_32(&c.identity.community_id.0),
785                            "version": 2,
786                            "name": c.name,
787                            "description": c.description,
788                            "is_owner": is_owner,
789                            "channels": c.channels.iter()
790                                .map(|ch| serde_json::json!({ "channel_id": crate::simd::hex::bytes_to_hex_32(&ch.id.0), "name": ch.name, "private": ch.private }))
791                                .collect::<Vec<_>>(),
792                        }));
793                    }
794                }
795                _ => {
796                    if let Ok(Some(c)) = crate::db::community::load_community(&id) {
797                        out.push(serde_json::json!({
798                            "community_id": c.id.to_hex(),
799                            "version": 1,
800                            "name": c.name,
801                            "description": c.description,
802                            "is_owner": crate::community::service::is_proven_owner(&c),
803                            "channels": c.channels.iter()
804                                .map(|ch| serde_json::json!({ "channel_id": ch.id.to_hex(), "name": ch.name }))
805                                .collect::<Vec<_>>(),
806                        }));
807                    }
808                }
809            }
810        }
811        out
812    }
813
814    /// Create a fresh **Concord v2** community owned by the local identity (the
815    /// SDK's default; the GUI's `create_community` stays v1 during the migration
816    /// window). Mints the self-certifying id + genesis, persists, publishes, and
817    /// registers each channel as a chat. Returns a `version: 2` JSON summary.
818    pub async fn create_community_v2(&self, name: &str) -> Result<serde_json::Value> {
819        use crate::community::{v2::service as v2, transport::LiveTransport};
820        let relays: Vec<String> = crate::state::active_trusted_relays()
821            .await
822            .iter()
823            .map(|s| s.to_string())
824            .collect();
825        if relays.is_empty() {
826            return Err(VectorError::Other("no relays available to host the Community".into()));
827        }
828        let session = state::SessionGuard::capture();
829        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
830        let community = v2::create_community(&transport, name, relays, None)
831            .await
832            .map_err(VectorError::Other)?;
833        self.register_v2_chats(&community, &session).await;
834        // Start streaming this community's planes right away.
835        if let Some(client) = state::nostr_client() {
836            crate::community::v2::realtime::refresh_subscription(&client).await;
837        }
838        Ok(Self::v2_summary(&community))
839    }
840
841    /// If `channel_id` belongs to a locally-held **v2** community, its
842    /// `CommunityId`; `Ok(None)` for a v1 channel or unknown. The routing key for
843    /// every dual-stack message op — a DB read error PROPAGATES (fail-closed)
844    /// instead of silently routing a v2 channel down the v1 path on a transient
845    /// failure.
846    fn v2_community_for_channel(&self, channel_id: &str) -> Result<Option<crate::community::CommunityId>> {
847        use crate::community::ConcordProtocol;
848        let Some(cid_hex) = crate::db::community::community_id_for_channel(channel_id).map_err(VectorError::Other)? else {
849            return Ok(None);
850        };
851        let cid = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid_hex));
852        Ok(match crate::db::community::community_protocol(&cid).map_err(VectorError::Other)? {
853            Some(ConcordProtocol::V2) => Some(cid),
854            _ => None,
855        })
856    }
857
858    /// The `version: 2` JSON summary the SDK/facade hands back for a v2 community.
859    fn v2_summary(community: &crate::community::v2::community::CommunityV2) -> serde_json::Value {
860        let me = state::my_public_key();
861        let is_owner = me.is_some_and(|m| community.owner().is_ok_and(|o| o == m));
862        serde_json::json!({
863            "community_id": crate::simd::hex::bytes_to_hex_32(&community.identity.community_id.0),
864            "version": 2,
865            "name": community.name,
866            "description": community.description,
867            "is_owner": is_owner,
868            "channels": community.channels.iter()
869                .map(|c| serde_json::json!({ "channel_id": crate::simd::hex::bytes_to_hex_32(&c.id.0), "name": c.name, "private": c.private }))
870                .collect::<Vec<_>>(),
871        })
872    }
873
874    /// Register each of a v2 community's channels as a chat row (so it surfaces in
875    /// the chat list / `communities()`), mirroring the v1 create path. `session`
876    /// is captured by the caller BEFORE its network I/O, so this STATE write is
877    /// skipped if the account swapped mid-flight (else we'd write A's community
878    /// into B's in-memory chats).
879    async fn register_v2_chats(&self, community: &crate::community::v2::community::CommunityV2, session: &state::SessionGuard) {
880        let owner_npub = community.owner().ok().and_then(|p| ToBech32::to_bech32(&p).ok());
881        let me = state::my_public_key();
882        let is_owner = me.is_some_and(|m| community.owner().is_ok_and(|o| o == m));
883        let id_hex = crate::simd::hex::bytes_to_hex_32(&community.identity.community_id.0);
884        let mut st = state::STATE.lock().await;
885        if !session.is_valid() {
886            return; // account swapped during the join/create — don't write into the new one.
887        }
888        for ch in &community.channels {
889            st.upsert_community_chat(
890                &crate::simd::hex::bytes_to_hex_32(&ch.id.0),
891                &community.name,
892                community.description.as_deref().unwrap_or(""),
893                &id_hex,
894                is_owner,
895                false,
896                owner_npub.as_deref(),
897                Some(community.created_at_ms),
898                community.dissolved,
899            );
900        }
901    }
902
903    /// Join a Community from a public invite URL (`vectorapp.io/invite#...`). Fetches the
904    /// token-encrypted bundle, persists the member-view Community, and registers its channels
905    /// as chats. Returns a JSON summary.
906    pub async fn join_community(&self, invite_url: &str) -> Result<serde_json::Value> {
907        use crate::community::{public_invite, service, transport::LiveTransport};
908        // Dual-stack: a v2 link is `…/invite/<naddr>#<fragment>` (a naddr in the
909        // path); a v1 link is `…/invite#<base64url>` (fragment only). Try the v2
910        // parser first — it only succeeds on the v2 shape — then fall through to v1.
911        if crate::community::v2::invite::parse_invite_link(invite_url).is_ok() {
912            let session = state::SessionGuard::capture();
913            let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
914            let community = crate::community::v2::service::accept_public_link(&transport, invite_url)
915                .await
916                .map_err(VectorError::Other)?;
917            self.register_v2_chats(&community, &session).await;
918            if let Some(client) = state::nostr_client() {
919                crate::community::v2::realtime::refresh_subscription(&client).await;
920            }
921            return Ok(Self::v2_summary(&community));
922        }
923        let (relays, token) = public_invite::parse_invite_url(invite_url)
924            .map_err(|e| VectorError::Other(e.to_string()))?;
925        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
926        let bundle = service::fetch_public_invite(&transport, &relays, &token)
927            .await
928            .map_err(VectorError::Other)?;
929        let now = std::time::SystemTime::now()
930            .duration_since(std::time::UNIX_EPOCH)
931            .map(|d| d.as_secs())
932            .unwrap_or(0);
933        let community = service::accept_public_invite(&bundle, now).map_err(VectorError::Other)?;
934        // Attribute our join presence to the link we used (creator + label) so the owner's per-link
935        // counter ticks. Mirrors the desktop public-join path.
936        let attribution = bundle.creator_npub.clone().map(|by| (by, bundle.label.clone()));
937        self.finalize_member_join(community, &transport, attribution).await
938    }
939
940    /// List the parked private invites (giftwrapped) awaiting acceptance. Each entry is the
941    /// community id, its name (from the stored bundle), and the inviter's npub.
942    pub fn list_pending_invites(&self) -> Result<Vec<serde_json::Value>> {
943        let rows = crate::db::community::list_pending_invites().map_err(VectorError::Other)?;
944        Ok(rows.iter().map(|p| {
945            // A v2 bundle carries owner_salt/community_root and self-certifies its
946            // owner; a successful (validating) v2 parse means the modern protocol.
947            if let Ok(v2) = crate::community::v2::invite::CommunityInvite::from_bundle_json(&p.bundle_json) {
948                serde_json::json!({
949                    "community_id": p.community_id,
950                    "name": v2.name,
951                    "inviter_npub": p.inviter_npub,
952                    "version": 2,
953                })
954            } else {
955                let name = crate::community::invite::CommunityInvite::from_json(&p.bundle_json)
956                    .ok().map(|i| i.name).unwrap_or_default();
957                serde_json::json!({
958                    "community_id": p.community_id,
959                    "name": name,
960                    "inviter_npub": p.inviter_npub,
961                    "version": 1,
962                })
963            }
964        }).collect())
965    }
966
967    /// Accept a PARKED private invite by community id: rebuild the member-view Community from the stored
968    /// bundle, finalize the join exactly like a public link, then drop the pending row. Mirrors the
969    /// desktop's consent-then-join for an invite delivered over a gift wrap.
970    pub async fn accept_pending_invite(&self, community_id: &str) -> Result<serde_json::Value> {
971        use crate::community::transport::LiveTransport;
972        let bundle_json = crate::db::community::get_pending_invite(community_id)
973            .map_err(VectorError::Other)?
974            .ok_or_else(|| VectorError::Other(format!("no pending invite for {community_id}")))?;
975        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
976
977        // Dual-stack: a validating v2 bundle parse means a v2 Direct Invite.
978        if crate::community::v2::invite::CommunityInvite::from_bundle_json(&bundle_json).is_ok() {
979            let session = state::SessionGuard::capture();
980            // The inviter's hex (parked at receive) attributes the Guestbook Join.
981            let inviter = crate::db::community::list_pending_invites()
982                .ok()
983                .and_then(|rows| rows.into_iter().find(|p| p.community_id == community_id).map(|p| p.inviter_npub));
984            // On failure the parked row is LEFT INTACT for retry — we must NOT auto-delete
985            // on a verify failure: the multi-relay transport launders an unreachable-relay
986            // error into an empty fetch, which yields the same "could not verify" as a
987            // forged root (and a control-plane flood does too), so an auto-delete would
988            // erase a GENUINE invite on a transient blip or an attacker's flood. A
989            // pre-planted forged-root bundle (deferred protocol residual) is instead
990            // cleared by the user declining it.
991            let community = crate::community::v2::service::accept_parked_invite(&transport, &bundle_json, inviter.as_deref())
992                .await
993                .map_err(VectorError::Other)?;
994            if !session.is_valid() {
995                return Err(VectorError::Other("account changed during join".into()));
996            }
997            self.register_v2_chats(&community, &session).await;
998            if let Some(client) = state::nostr_client() {
999                crate::community::v2::realtime::refresh_subscription(&client).await;
1000            }
1001            crate::community::v2::realtime::enqueue_follow(community.id());
1002            let _ = crate::db::community::delete_pending_invite(community_id);
1003            return Ok(Self::v2_summary(&community));
1004        }
1005
1006        // v1 route.
1007        use crate::community::invite::{accept_invite, CommunityInvite};
1008        let invite = CommunityInvite::from_json(&bundle_json).map_err(VectorError::Other)?;
1009        let community = accept_invite(&invite).map_err(VectorError::Other)?;
1010        // Private invites carry no public-link label; the inviter attribution metric is link-only.
1011        let summary = self.finalize_member_join(community, &transport, None).await?;
1012        let _ = crate::db::community::delete_pending_invite(community_id);
1013        Ok(summary)
1014    }
1015
1016    /// Shared finalization for joining a Community as a member — public link OR accepted private invite.
1017    /// Walks any base rekey, folds the LATEST control plane (so the joiner sees current metadata, not
1018    /// the bundle's genesis snapshot), refuses if banned, registers the channels as chats, and announces
1019    /// presence. Returns the JSON summary.
1020    pub(crate) async fn finalize_member_join<T: crate::community::transport::Transport + ?Sized>(
1021        &self,
1022        community: crate::community::Community,
1023        transport: &T,
1024        attribution: Option<(String, Option<String>)>,
1025    ) -> Result<serde_json::Value> {
1026        use crate::community::service;
1027        // Persist the member-view row up front: the catch-up, the control fold, and chat registration all
1028        // read it back from the DB. A private bundle (unlike a public one with a preview) arrives with no
1029        // display metadata, so nothing else would have saved it. UPSERT — re-saving a public join is a no-op.
1030        crate::db::community::save_community(&community).map_err(VectorError::Other)?;
1031        // The bundle's root can predate a base rotation, so walk any rekey first (no-op if none) — then
1032        // re-load so the control fold + registration happen at the CURRENT epoch.
1033        if let Ok(c) = service::catch_up_server_root(transport, &community).await {
1034            if c.removed {
1035                let _ = crate::db::community::delete_community(&community.id.to_hex());
1036                return Err(VectorError::Other("you have been removed from this community".into()));
1037            }
1038        }
1039        let community = crate::db::community::load_community(&community.id)
1040            .map_err(VectorError::Other)?
1041            .unwrap_or(community);
1042        // Fold the LATEST control plane before we register anything — the joiner should see the current
1043        // name/description/roster/mode immediately, not a stale snapshot. Banlist first: an honest client
1044        // REFUSES to join if this npub is banned (and the just-saved community is torn back down).
1045        let _ = service::fetch_and_apply_control(transport, &community).await;
1046        if service::am_i_banned(&community) {
1047            let _ = crate::db::community::delete_community(&community.id.to_hex());
1048            return Err(VectorError::Other("you are banned from this community".into()));
1049        }
1050        // Re-load so the chat we register + the summary we return carry the freshly-folded latest metadata.
1051        let community = crate::db::community::load_community(&community.id)
1052            .map_err(VectorError::Other)?
1053            .unwrap_or(community);
1054        let owner_npub = community
1055            .owner_attestation
1056            .as_ref()
1057            .and_then(|att| crate::community::owner::verify_owner_attestation(att, &community.id.to_hex()))
1058            .and_then(|pk| ToBech32::to_bech32(&pk).ok());
1059        {
1060            let created_at_ms = crate::db::community::community_created_at_ms(&community.id);
1061            let mut st = state::STATE.lock().await;
1062            for ch in &community.channels {
1063                st.upsert_community_chat(
1064                    &ch.id.to_hex(),
1065                    &community.name,
1066                    community.description.as_deref().unwrap_or(""),
1067                    &community.id.to_hex(),
1068                    crate::community::service::is_proven_owner(&community),
1069                    community.icon.is_some(),
1070                    owner_npub.as_deref(),
1071                    created_at_ms,
1072                    community.dissolved,
1073                );
1074            }
1075        }
1076        // Best-effort join announcement (kind 3306) into the primary channel so honest peers
1077        // see us in their member list even before we post. Failure must not fail the join.
1078        if let Some(primary) = community.channels.first() {
1079            let _ = service::publish_presence(transport, &community, primary, true, attribution).await;
1080        }
1081        Ok(serde_json::json!({
1082            "community_id": community.id.to_hex(),
1083            "version": 1,
1084            "name": community.name,
1085            "channels": community.channels.iter()
1086                .map(|c| serde_json::json!({ "channel_id": c.id.to_hex(), "name": c.name }))
1087                .collect::<Vec<_>>(),
1088        }))
1089    }
1090
1091    /// Create a Community (single "general" channel) on the default trusted relays. Signs the
1092    /// owner attestation with this identity (so the creator is the proven owner), registers the
1093    /// channel as a chat, and returns a JSON summary.
1094    pub async fn create_community(&self, name: &str) -> Result<serde_json::Value> {
1095        use crate::community::{service, transport::LiveTransport};
1096        let relays: Vec<String> = crate::state::active_trusted_relays()
1097            .await
1098            .iter()
1099            .map(|s| s.to_string())
1100            .collect();
1101        if relays.is_empty() {
1102            return Err(VectorError::Other("no relays available to host the Community".into()));
1103        }
1104        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1105        let community = service::create_community(&transport, name, "general", relays)
1106            .await
1107            .map_err(VectorError::Other)?;
1108        let owner_npub = community
1109            .owner_attestation
1110            .as_ref()
1111            .and_then(|att| crate::community::owner::verify_owner_attestation(att, &community.id.to_hex()))
1112            .and_then(|pk| ToBech32::to_bech32(&pk).ok());
1113        {
1114            let created_at_ms = crate::db::community::community_created_at_ms(&community.id);
1115            let mut st = state::STATE.lock().await;
1116            for ch in &community.channels {
1117                st.upsert_community_chat(
1118                    &ch.id.to_hex(),
1119                    &community.name,
1120                    community.description.as_deref().unwrap_or(""),
1121                    &community.id.to_hex(),
1122                    crate::community::service::is_proven_owner(&community),
1123                    community.icon.is_some(),
1124                    owner_npub.as_deref(),
1125                    created_at_ms,
1126                    community.dissolved,
1127                );
1128            }
1129        }
1130        Ok(serde_json::json!({
1131            "community_id": community.id.to_hex(),
1132            "version": 1,
1133            "name": community.name,
1134            "channels": community.channels.iter()
1135                .map(|c| serde_json::json!({ "channel_id": c.id.to_hex(), "name": c.name }))
1136                .collect::<Vec<_>>(),
1137        }))
1138    }
1139
1140    /// Mint a public invite link for a Community this identity owns. Returns the shareable URL.
1141    pub async fn create_public_invite(&self, community_id: &str) -> Result<String> {
1142        use crate::community::{service, transport::LiveTransport, CommunityId};
1143        if community_id.len() != 64 {
1144            return Err(VectorError::Other("malformed community id".into()));
1145        }
1146        let cid = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
1147        // Dual-stack: mint a v2 link for a v2 community (naddr#fragment).
1148        if let Some(Some(crate::community::ConcordProtocol::V2)) =
1149            crate::db::community::community_protocol(&cid).ok()
1150        {
1151            let community = crate::db::community::load_community_v2(&cid)
1152                .map_err(VectorError::Other)?
1153                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
1154            let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1155            // v2 `build_invite_url` appends its own `/invite/<naddr>`, so pass the
1156            // bare domain (strip the `/invite` the v1 constant carries).
1157            let base = crate::community::public_invite::INVITE_URL_BASE.trim_end_matches("/invite");
1158            let minted = crate::community::v2::service::mint_public_link(&transport, &community, base, None, None)
1159                .await
1160                .map_err(VectorError::Other)?;
1161            return Ok(minted.url);
1162        }
1163        let community = crate::db::community::load_community(&CommunityId(
1164            crate::simd::hex::hex_to_bytes_32(community_id),
1165        ))
1166        .map_err(VectorError::Other)?
1167        .ok_or_else(|| VectorError::Other("community not found".into()))?;
1168        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1169        let (_token, url) = service::create_public_invite(&transport, &community, None, None)
1170            .await
1171            .map_err(VectorError::Other)?;
1172        Ok(url)
1173    }
1174
1175    /// Send a PRIVATE invite: gift-wrap this Community's invite bundle directly to an npub over a NIP-17
1176    /// DM (the same transport as a regular DM). The invitee parks it pending consent (accept_pending_invite).
1177    /// Requires CREATE_INVITE; a banned npub can't be re-invited. Returns the wrap's event id + relays.
1178    pub async fn invite_to_community(&self, community_id: &str, invitee_npub: &str) -> Result<serde_json::Value> {
1179        use crate::community::{service, CommunityId};
1180        use crate::sending::{send_rumor_dm, NoOpSendCallback, SendCallback, SendConfig};
1181
1182        let session = crate::state::SessionGuard::capture();
1183        let my_pk = crate::state::my_public_key()
1184            .ok_or_else(|| VectorError::Other("Public key not set".into()))?;
1185
1186        if community_id.len() != 64 {
1187            return Err(VectorError::Other("malformed community id".into()));
1188        }
1189        let cid = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
1190        // Dual-stack: a v2 community sends a Direct Invite (3313 giftwrap).
1191        // DELIBERATELY ungated, unlike v1's CREATE_INVITE + banlist pre-check: a
1192        // Direct Invite is an ungateable key handoff (CORD-05 §6 — "any keyholder
1193        // can whisper keys"), so any member may extend one; the real access cut is
1194        // the rekey, not a permission on inviting.
1195        if let Some(Some(crate::community::ConcordProtocol::V2)) =
1196            crate::db::community::community_protocol(&cid).ok()
1197        {
1198            let community = crate::db::community::load_community_v2(&cid)
1199                .map_err(VectorError::Other)?
1200                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
1201            let recipient = nostr_sdk::prelude::PublicKey::parse(invitee_npub)
1202                .map_err(|e| VectorError::Other(format!("bad invitee npub: {e}")))?;
1203            let client = crate::state::nostr_client().ok_or_else(|| VectorError::Other("Not connected".into()))?;
1204            // Gift-wrap the 3313 Direct-Invite rumor (the bundle JSON) to the RECIPIENT'S
1205            // inbox relays (kind-10050) — a not-yet-member sees it on their DM sub;
1206            // the community relays wouldn't reach them. `#k=3313` per CORD-05 §6.
1207            let bundle = crate::community::v2::service::bundle_of(&community, Some(my_pk), None, None);
1208            let bundle_json = serde_json::to_string(&bundle).map_err(|e| VectorError::Other(e.to_string()))?;
1209            let rumor = nostr_sdk::EventBuilder::new(
1210                nostr_sdk::Kind::Custom(crate::community::v2::kind::DIRECT_INVITE),
1211                bundle_json,
1212            )
1213            .build(my_pk);
1214            let k_tag = nostr_sdk::Tag::custom(
1215                nostr_sdk::TagKind::Custom("k".into()),
1216                [crate::community::v2::kind::DIRECT_INVITE.to_string()],
1217            );
1218            if !session.is_valid() {
1219                return Err(VectorError::Other("account changed".into()));
1220            }
1221            crate::inbox_relays::send_gift_wrap(&client, &recipient, rumor, [k_tag])
1222                .await
1223                .map_err(VectorError::Other)?;
1224            return Ok(serde_json::json!({ "invited": invitee_npub, "version": 2 }));
1225        }
1226        let community = crate::db::community::load_community(&CommunityId(
1227            crate::simd::hex::hex_to_bytes_32(community_id),
1228        ))
1229        .map_err(VectorError::Other)?
1230        .ok_or_else(|| VectorError::Other("community not found".into()))?;
1231
1232        if !service::caller_has_permission(&community, crate::community::roles::Permissions::CREATE_INVITE) {
1233            return Err(VectorError::Other("You need the create-invite permission to invite someone".into()));
1234        }
1235        let invitee_hex = nostr_sdk::PublicKey::parse(invitee_npub)
1236            .map_err(|_| VectorError::Other("invalid npub".into()))?
1237            .to_hex();
1238        if crate::db::community::get_community_banlist(community_id)
1239            .map_err(VectorError::Other)?
1240            .iter()
1241            .any(|b| b == &invitee_hex)
1242        {
1243            return Err(VectorError::Other("That member is banned from this community and can't be invited".into()));
1244        }
1245
1246        // The bundle is built from purely local state; bail if the account swapped before the gift-wrap.
1247        if !session.is_valid() {
1248            return Err(VectorError::Other("account changed during invite".into()));
1249        }
1250
1251        let rumor = crate::community::invite::build_invite_rumor(&community, my_pk).map_err(VectorError::Other)?;
1252        let pending_id = format!("community-invite-{}", community_id);
1253        // self_send=false: the owner already holds the Community; the inbound guard would drop the echo.
1254        let config = SendConfig { self_send: false, ..SendConfig::gui() };
1255        let callback: Arc<dyn SendCallback> = Arc::new(NoOpSendCallback);
1256
1257        let result = send_rumor_dm(invitee_npub, &pending_id, rumor, &config, callback)
1258            .await
1259            .map_err(VectorError::Other)?;
1260
1261        Ok(serde_json::json!({
1262            "community_id": community_id,
1263            "invitee": invitee_npub,
1264            "wrap_event_id": result.event_id,
1265        }))
1266    }
1267
1268    /// The public invite links this account minted for a Community (to list + revoke). Each carries
1269    /// the hex `token` (the link secret) needed by [`Self::revoke_public_invite`]. A local read for
1270    /// both protocols — links minted on this device (a v2 mint also syncs the cross-device 13303
1271    /// record; v2 `join_count` is not yet tracked and is always 0).
1272    pub fn list_public_invites(&self, community_id: &str) -> Result<Vec<crate::db::community::PublicInviteRecord>> {
1273        crate::db::community::list_public_invites(community_id).map_err(VectorError::Other)
1274    }
1275
1276    /// Revoke a public invite link by its hex token. Retiring the LAST active link flips the Community to
1277    /// Private, which re-founds (rotates the base key + every channel key) to cut link-joined lurkers.
1278    /// Idempotent: a token this account doesn't hold is a no-op. Needs a local key when the revoke triggers
1279    /// the privatize rekey (a bunker account can't rotate).
1280    pub async fn revoke_public_invite(&self, community_id: &str, token: &str) -> Result<()> {
1281        use crate::community::{service, transport::LiveTransport, CommunityId};
1282        if community_id.len() != 64 {
1283            return Err(VectorError::Other("malformed community id".into()));
1284        }
1285        let cid = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
1286        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(20));
1287        // Dual-stack: a v2 link is retired by its 16-byte token hex (re-post the
1288        // coordinate as a tombstone + tombstone the 13303 entry + refresh the Registry).
1289        if let Some(Some(crate::community::ConcordProtocol::V2)) = crate::db::community::community_protocol(&cid).ok() {
1290            let community = crate::db::community::load_community_v2(&cid)
1291                .map_err(VectorError::Other)?
1292                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
1293            return crate::community::v2::service::revoke_public_link(&transport, &community, token)
1294                .await
1295                .map_err(VectorError::Other);
1296        }
1297        let token_bytes = crate::simd::hex::hex_to_bytes_32(token);
1298        let community = crate::db::community::load_community(&cid)
1299            .map_err(VectorError::Other)?
1300            .ok_or_else(|| VectorError::Other("community not found".into()))?;
1301        service::revoke_public_invite(&transport, &community, &token_bytes)
1302            .await
1303            .map_err(VectorError::Other)
1304    }
1305
1306    /// Post a text message to a Community channel. Returns the message id (the inner id).
1307    pub async fn send_community_message(
1308        &self,
1309        channel_id: &str,
1310        content: &str,
1311        replied_to: Option<&str>,
1312    ) -> Result<String> {
1313        use crate::community::{envelope, inbound, service, transport::LiveTransport};
1314        // Dual-stack: route by the owning community's stored protocol.
1315        if let Some(id) = self.v2_community_for_channel(channel_id)? {
1316            let community = crate::db::community::load_community_v2(&id)
1317                .map_err(VectorError::Other)?
1318                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
1319            let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
1320            let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1321            // The NIP-C7 q tag's author slot is a SHOULD — best-effort from the
1322            // held message, empty (= unknown) when the parent isn't in memory.
1323            let reply = match replied_to.filter(|r| !r.is_empty()) {
1324                Some(parent_id) => {
1325                    let author_hex = {
1326                        let st = state::STATE.lock().await;
1327                        st.find_message(parent_id)
1328                            .and_then(|(_, m)| m.npub.as_deref().and_then(|n| nostr_sdk::prelude::PublicKey::parse(n).ok()))
1329                            .map(|pk| pk.to_hex())
1330                            .unwrap_or_default()
1331                    };
1332                    Some((parent_id.to_string(), author_hex))
1333                }
1334                None => None,
1335            };
1336            let reply_ref = reply.as_ref().map(|(id, author)| (id.as_str(), author.as_str()));
1337            return crate::community::v2::service::send_chat_message(&transport, &community, &ch, content, reply_ref, &[], vec![])
1338                .await
1339                .map_err(VectorError::Other);
1340        }
1341        let (community, channel) = self.resolve_channel(channel_id)?;
1342        let author_pk = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
1343        let reply = replied_to.filter(|r| !r.is_empty());
1344        let ms = std::time::SystemTime::now()
1345            .duration_since(std::time::UNIX_EPOCH)
1346            .map(|d| d.as_millis() as u64)
1347            .unwrap_or(0);
1348        let unsigned = envelope::build_inner_typed(
1349            author_pk,
1350            &channel.id,
1351            channel.epoch,
1352            crate::stored_event::event_kind::COMMUNITY_MESSAGE,
1353            content,
1354            ms,
1355            reply,
1356            &[],
1357        );
1358        let message_id = unsigned.id.ok_or_else(|| VectorError::Other("inner event has no id".into()))?.to_hex();
1359        let client = state::nostr_client().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
1360        let signer = client.signer().await.map_err(|e| VectorError::Other(format!("Signer unavailable: {e}")))?;
1361        let inner = unsigned.sign(&signer).await.map_err(|e| VectorError::Other(format!("sign: {e}")))?;
1362        let session = state::SessionGuard::capture();
1363        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1364        let outer = service::send_signed_message(&transport, &community, &channel, &inner)
1365            .await
1366            .map_err(VectorError::Other)?;
1367        // Local echo so get_messages reflects the send (the relay echo dedups on inner id).
1368        // A swap during the publish must not echo account A's message into account B.
1369        if !session.is_valid() {
1370            return Ok(message_id);
1371        }
1372        let echoed = {
1373            let mut st = state::STATE.lock().await;
1374            inbound::process_incoming(&mut st, &outer, &channel, &author_pk)
1375        };
1376        if let Some(inbound::IncomingEvent::NewMessage(msg)) = echoed {
1377            let _ = crate::db::events::save_message(channel_id, &msg).await;
1378        }
1379        Ok(message_id)
1380    }
1381
1382    /// Send a file to a Community channel as an encrypted attachment. Returns the message id.
1383    /// Mirrors the DM file pipeline (encrypt → Blossom upload → NIP-92 `imeta`) but publishes
1384    /// over the community transport.
1385    pub async fn send_community_file(&self, channel_id: &str, file_path: &str) -> Result<String> {
1386        use crate::community::{attachments, envelope, inbound, service, transport::LiveTransport};
1387        let path = std::path::Path::new(file_path);
1388        let bytes = std::fs::read(path).map_err(VectorError::Io)?;
1389        if bytes.is_empty() {
1390            return Err(VectorError::Other("Empty file".into()));
1391        }
1392        let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("file").to_string();
1393        let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("bin").to_lowercase();
1394
1395        // Snapshot the session BEFORE the upload: the destination below is resolved
1396        // from THIS account's DB, and the upload can outlive an account swap.
1397        let session = state::SessionGuard::capture();
1398        // Dual-stack: resolve the destination BEFORE the upload so a bad channel
1399        // fails fast (never spend an upload on an unroutable send).
1400        let v2_target = match self.v2_community_for_channel(channel_id)? {
1401            Some(id) => Some(
1402                crate::db::community::load_community_v2(&id)
1403                    .map_err(VectorError::Other)?
1404                    .ok_or_else(|| VectorError::Other("v2 community not found".into()))?,
1405            ),
1406            None => None,
1407        };
1408        let v1_target = match v2_target {
1409            Some(_) => None,
1410            None => Some(self.resolve_channel(channel_id)?),
1411        };
1412        let author_pk = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
1413
1414        let file_hash = crate::crypto::sha256_hex(&bytes);
1415        let mime = crate::crypto::mime_from_extension(&extension);
1416        let img_meta = crate::crypto::generate_image_metadata(&bytes);
1417
1418        // Save the plaintext locally (hash-keyed) so the sender previews it instantly.
1419        let download_dir = crate::db::get_download_dir();
1420        let _ = std::fs::create_dir_all(&download_dir);
1421        let local_name = if filename.is_empty() { format!("{}.{}", &file_hash, extension) } else { filename.clone() };
1422        let local_path = crate::crypto::resolve_unique_filename(&download_dir, &local_name);
1423        let _ = std::fs::write(&local_path, &bytes);
1424
1425        // Encrypt → upload to Blossom (signer reused for the envelope below).
1426        let params = crate::crypto::generate_encryption_params();
1427        let encrypted = crate::crypto::encrypt_data(&bytes, &params)?;
1428        let encrypted_size = encrypted.len() as u64;
1429
1430        let client = state::nostr_client().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
1431        let signer = client.signer().await.map_err(|e| VectorError::Other(format!("Signer unavailable: {e}")))?;
1432        let servers = crate::blossom_servers::compute_enabled_servers();
1433        if servers.is_empty() {
1434            return Err(VectorError::Other("No Blossom servers configured".into()));
1435        }
1436        let noop_progress: crate::blossom::ProgressCallback = std::sync::Arc::new(|_, _| Ok(()));
1437        let url = crate::blossom::upload_blob_with_progress_and_failover(
1438            signer.clone(),
1439            servers,
1440            std::sync::Arc::new(encrypted),
1441            Some(mime),
1442            /* is_encrypted */ true,
1443            noop_progress,
1444            Some(3),
1445            Some(std::time::Duration::from_secs(2)),
1446            None,
1447        ).await.map_err(VectorError::Other)?;
1448
1449        let attachment = crate::types::Attachment {
1450            id: file_hash.clone(),
1451            key: params.key.clone(),
1452            nonce: params.nonce.clone(),
1453            extension: extension.clone(),
1454            name: filename.clone(),
1455            url,
1456            path: local_path.to_string_lossy().to_string(),
1457            size: encrypted_size,
1458            img_meta,
1459            downloading: false,
1460            downloaded: true,
1461            ..Default::default()
1462        };
1463        let imeta = vec![attachments::attachment_to_imeta(&attachment)];
1464
1465        // The upload straddled awaits — never publish a pre-swap destination.
1466        if !session.is_valid() {
1467            return Err(VectorError::Other("account changed during upload".into()));
1468        }
1469        // v2: the imeta rides the kind-9 rumor verbatim (NIP-92), content empty.
1470        if let Some(community) = v2_target {
1471            let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
1472            let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(30));
1473            return crate::community::v2::service::send_chat_message(&transport, &community, &ch, "", None, &[], imeta)
1474                .await
1475                .map_err(VectorError::Other);
1476        }
1477        let (community, channel) = v1_target.expect("v1 target resolved when no v2 community matched");
1478        let ms = std::time::SystemTime::now()
1479            .duration_since(std::time::UNIX_EPOCH)
1480            .map(|d| d.as_millis() as u64)
1481            .unwrap_or(0);
1482        let unsigned = envelope::build_inner_full(
1483            author_pk, &channel.id, channel.epoch,
1484            stored_event::event_kind::COMMUNITY_MESSAGE, "", ms, None, &[], &imeta,
1485        );
1486        let message_id = unsigned.id.ok_or_else(|| VectorError::Other("inner event has no id".into()))?.to_hex();
1487        let inner = unsigned.sign(&signer).await.map_err(|e| VectorError::Other(format!("sign: {e}")))?;
1488        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(30));
1489        let outer = service::send_signed_message(&transport, &community, &channel, &inner)
1490            .await.map_err(VectorError::Other)?;
1491        // Local echo so get_messages reflects the send.
1492        let echoed = {
1493            let mut st = state::STATE.lock().await;
1494            inbound::process_incoming(&mut st, &outer, &channel, &author_pk)
1495        };
1496        if let Some(inbound::IncomingEvent::NewMessage(m)) = echoed {
1497            let _ = crate::db::events::save_message(channel_id, &m).await;
1498        }
1499        Ok(message_id)
1500    }
1501
1502    /// Send an ephemeral typing indicator to a Community channel.
1503    pub async fn send_community_typing(&self, channel_id: &str) -> Result<()> {
1504        use crate::community::{service, transport::LiveTransport};
1505        if let Some(id) = self.v2_community_for_channel(channel_id)? {
1506            let community = crate::db::community::load_community_v2(&id)
1507                .map_err(VectorError::Other)?
1508                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
1509            let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
1510            let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(8));
1511            return crate::community::v2::service::send_typing(&transport, &community, &ch)
1512                .await
1513                .map_err(VectorError::Other);
1514        }
1515        let (community, channel) = self.resolve_channel(channel_id)?;
1516        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(8));
1517        service::publish_typing_signal(&transport, &community, &channel)
1518            .await
1519            .map_err(VectorError::Other)
1520    }
1521
1522    /// React to a Community message. `emoji_url` carries the NIP-30 image URL for a custom
1523    /// `:shortcode:` reaction (parity with DMs).
1524    pub async fn send_community_reaction(
1525        &self,
1526        channel_id: &str,
1527        message_id: &str,
1528        emoji: &str,
1529        emoji_url: Option<&str>,
1530    ) -> Result<()> {
1531        let emoji_tags: Vec<crate::types::EmojiTag> = match emoji_url {
1532            Some(url) if emoji.starts_with(':') && emoji.ends_with(':') && emoji.len() >= 3 && !url.is_empty() => {
1533                vec![crate::types::EmojiTag { shortcode: emoji[1..emoji.len() - 1].to_string(), url: url.to_string() }]
1534            }
1535            _ => Vec::new(),
1536        };
1537        if let Some(id) = self.v2_community_for_channel(channel_id)? {
1538            let session = state::SessionGuard::capture();
1539            let community = crate::db::community::load_community_v2(&id)
1540                .map_err(VectorError::Other)?
1541                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
1542            let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
1543            let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1544            // NIP-25 names the reacted-to author (a required `p`). STATE first, then
1545            // the persisted row (v2 history + the send echo live in the shared events
1546            // store, so this almost always resolves locally); the channel-page fetch
1547            // is the last resort for a target this device never saw.
1548            let held = {
1549                let st = state::STATE.lock().await;
1550                st.find_message(message_id)
1551                    .and_then(|(_, m)| m.npub.as_deref().and_then(|n| nostr_sdk::prelude::PublicKey::parse(n).ok()))
1552            };
1553            let held = held.or_else(|| {
1554                crate::db::events::event_author(message_id)
1555                    .ok()
1556                    .flatten()
1557                    .and_then(|n| nostr_sdk::prelude::PublicKey::parse(&n).ok())
1558            });
1559            let target_author = match held {
1560                Some(pk) => pk,
1561                None => crate::community::v2::service::fetch_channel(&transport, &community, &ch, 500)
1562                    .await
1563                    .map_err(VectorError::Other)?
1564                    .iter()
1565                    .find(|f| f.event.opened().rumor_id.to_hex() == message_id)
1566                    .map(|f| f.event.opened().author)
1567                    .ok_or_else(|| VectorError::Other("reacted-to message not found".into()))?,
1568            };
1569            // The author lookup straddled awaits against THIS account's community.
1570            if !session.is_valid() {
1571                return Err(VectorError::Other("account changed before send".into()));
1572            }
1573            let pair = emoji_tags.first().map(|t| (t.shortcode.as_str(), t.url.as_str()));
1574            // The NIP-25 `k` names the target's rumor kind. Stored rows don't keep
1575            // wire-kind fidelity yet, so a reaction to a received kind-1111 thread
1576            // reply claims `9` — Armada's fold ignores reaction `k`, and exact
1577            // threading lands with the thread-aware GUI.
1578            return crate::community::v2::service::send_reaction(
1579                &transport, &community, &ch, message_id, &target_author.to_hex(), crate::community::v2::kind::MESSAGE, emoji, pair,
1580            )
1581            .await
1582            .map(|_| ())
1583            .map_err(VectorError::Other);
1584        }
1585        self.publish_community_control(
1586            channel_id, stored_event::event_kind::COMMUNITY_REACTION, emoji, message_id, &emoji_tags,
1587        ).await
1588    }
1589
1590    /// Edit one of your own Community messages.
1591    pub async fn edit_community_message(&self, channel_id: &str, message_id: &str, new_content: &str) -> Result<()> {
1592        let emoji_tags = emoji_packs::resolve_outbound_emoji_tags(new_content);
1593        if let Some(id) = self.v2_community_for_channel(channel_id)? {
1594            let community = crate::db::community::load_community_v2(&id)
1595                .map_err(VectorError::Other)?
1596                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
1597            let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
1598            let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1599            return crate::community::v2::service::send_edit(&transport, &community, &ch, message_id, new_content)
1600                .await
1601                .map(|_| ())
1602                .map_err(VectorError::Other);
1603        }
1604        self.publish_community_control(
1605            channel_id, stored_event::event_kind::COMMUNITY_EDIT, new_content, message_id, &emoji_tags,
1606        ).await
1607    }
1608
1609    /// Delete one of your own Community messages, resolving its channel from local
1610    /// state (the GUI path). A headless v2 consumer holds no local history — use
1611    /// [`Self::delete_community_message_in`] with the channel id instead.
1612    pub async fn delete_community_message(&self, message_id: &str) -> Result<()> {
1613        let channel_id = {
1614            let st = state::STATE.lock().await;
1615            match st.find_message(message_id) {
1616                Some((chat, _)) => chat.id.clone(),
1617                None => return Err(VectorError::Other("message not found (already deleted?)".into())),
1618            }
1619        };
1620        self.delete_community_message_in(&channel_id, message_id).await
1621    }
1622
1623    /// Delete one of your own Community messages in `channel_id`: a NIP-09 relay nuke when the
1624    /// per-message key is held (v1) or the in-plane kind-5 (v2), plus a cooperative tombstone so
1625    /// peers hide it, plus best-effort attachment cleanup.
1626    pub async fn delete_community_message_in(&self, channel_id: &str, message_id: &str) -> Result<()> {
1627        use crate::community::{service, transport::LiveTransport};
1628        let session = state::SessionGuard::capture();
1629        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1630
1631        // Attachment URLs come from local state when held (a headless v2 consumer
1632        // has none — blob cleanup is then the receiving peers' concern, not ours).
1633        let attachment_urls: Vec<String> = {
1634            let st = state::STATE.lock().await;
1635            st.find_message(message_id)
1636                .map(|(_, msg)| msg.attachments.iter().filter(|a| !a.url.is_empty()).map(|a| a.url.clone()).collect())
1637                .unwrap_or_default()
1638        };
1639
1640        if let Some(id) = self.v2_community_for_channel(channel_id)? {
1641            // v2: the cooperative in-plane kind-5 (the wrap-ciphertext scrub needs
1642            // the ephemeral wrap key, not retained in this cut).
1643            let community = crate::db::community::load_community_v2(&id)
1644                .map_err(VectorError::Other)?
1645                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
1646            let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(&channel_id));
1647            crate::community::v2::service::send_delete(
1648                &transport, &community, &ch, message_id, crate::community::v2::kind::MESSAGE,
1649            )
1650            .await
1651            .map_err(VectorError::Other)?;
1652        } else {
1653            // Layer 1 — relay nuke against the retained per-message key (best-effort).
1654            if crate::db::community::get_message_key(message_id).map(|k| k.is_some()).unwrap_or(false) {
1655                let _ = service::delete_message(&transport, message_id).await;
1656            }
1657            // Layer 2 — cooperative tombstone so peers hide it.
1658            self.publish_community_control(
1659                &channel_id, stored_event::event_kind::COMMUNITY_DELETE, "", message_id, &[],
1660            ).await?;
1661        }
1662        // Layer 3 — best-effort attachment blob delete.
1663        if !attachment_urls.is_empty() {
1664            if let Some(client) = state::nostr_client() {
1665                if let Ok(signer) = client.signer().await {
1666                    crate::blossom::delete_blobs_best_effort(signer, attachment_urls);
1667                }
1668            }
1669        }
1670        // Local removal — the publishes above straddled awaits; a swap must not let this
1671        // strip the message from a swapped-in account's STATE + DB (message_id is global).
1672        if !session.is_valid() {
1673            return Ok(());
1674        }
1675        let removed_chat = {
1676            let mut st = state::STATE.lock().await;
1677            st.remove_message(message_id).map(|(cid, _)| cid)
1678        };
1679        let _ = crate::db::events::delete_event(message_id).await;
1680        traits::emit_event_json("message_removed", serde_json::json!({
1681            "id": message_id, "chat_id": removed_chat.as_deref().unwrap_or(&channel_id), "reason": "deleted",
1682        }));
1683        Ok(())
1684    }
1685
1686    /// Shared community control-event publish (reaction / edit / delete tombstone): build the
1687    /// inner-typed envelope, sign, send over the community transport, then locally echo + persist + emit.
1688    async fn publish_community_control(
1689        &self,
1690        channel_id: &str,
1691        kind: u16,
1692        content: &str,
1693        target: &str,
1694        emoji_tags: &[crate::types::EmojiTag],
1695    ) -> Result<()> {
1696        use crate::community::{envelope, inbound, service, transport::LiveTransport};
1697        let (community, channel) = self.resolve_channel(channel_id)?;
1698        let author_pk = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
1699        let ms = std::time::SystemTime::now()
1700            .duration_since(std::time::UNIX_EPOCH)
1701            .map(|d| d.as_millis() as u64)
1702            .unwrap_or(0);
1703        let unsigned = envelope::build_inner_typed(
1704            author_pk, &channel.id, channel.epoch, kind, content, ms, Some(target), emoji_tags,
1705        );
1706        let client = state::nostr_client().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
1707        let signer = client.signer().await.map_err(|e| VectorError::Other(format!("Signer unavailable: {e}")))?;
1708        let inner = unsigned.sign(&signer).await.map_err(|e| VectorError::Other(format!("sign: {e}")))?;
1709        let session = state::SessionGuard::capture();
1710        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1711        let outer = service::send_signed_message(&transport, &community, &channel, &inner)
1712            .await.map_err(VectorError::Other)?;
1713        // Local echo + persist + emit (relay echo dedups on inner id). A swap during the
1714        // publish must not echo account A's control event into account B.
1715        if !session.is_valid() {
1716            return Ok(());
1717        }
1718        let outcome = {
1719            let mut st = state::STATE.lock().await;
1720            inbound::process_incoming(&mut st, &outer, &channel, &author_pk)
1721        };
1722        if let Some(inbound::IncomingEvent::Updated { target_id, message, edit_event }) = outcome {
1723            if let Some(ev) = edit_event {
1724                let mut ev = (*ev).clone();
1725                if let Ok(cid) = crate::db::id_cache::get_chat_id_by_identifier(channel_id) { ev.chat_id = cid; }
1726                let _ = crate::db::events::save_event(&ev).await;
1727            } else {
1728                let _ = crate::db::events::save_message(channel_id, &message).await;
1729            }
1730            traits::emit_event_json("message_update", serde_json::json!({
1731                "old_id": target_id, "message": &message, "chat_id": channel_id,
1732            }));
1733        }
1734        Ok(())
1735    }
1736
1737    /// Catch a Community channel up from relays. v1: fetch + ingest the latest page of messages,
1738    /// reactions, edits, and deletes, returning how many were brand-new. v2: consensus catch-up
1739    /// only (rekeys + control refold) — chat history delivers over the live handler bridge, so the
1740    /// count is always 0. Returns `(new_message_count, warnings)`; `warnings` are NON-FATAL errors
1741    /// hit during the sync (catch-up, control fold, read-cut resume) — surfaced rather than
1742    /// swallowed so a headless caller is never blind to "the sync ran but a re-founding couldn't
1743    /// be resumed."
1744    pub async fn sync_community_channel(&self, channel_id: &str, limit: usize) -> Result<(usize, Vec<String>)> {
1745        use crate::community::{inbound, send, service, transport::LiveTransport};
1746        let my_pk = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
1747        // v2: consensus catch-up (rekeys then control refold) + chat backfill. With a
1748        // running listen() the coalescing worker owns the follow (never run inline beside
1749        // it — two concurrent follows can whole-row clobber); headless, walk it inline.
1750        // The chat page is fetched + persisted either way, so get_messages backfills.
1751        if let Some(id) = self.v2_community_for_channel(channel_id)? {
1752            let warnings = if community::v2::realtime::follow_worker_running() {
1753                community::v2::realtime::enqueue_follow(&id);
1754                Vec::new()
1755            } else {
1756                Self::v2_inline_follow(&id).await
1757            };
1758            let new = Self::v2_backfill_channel(&id, channel_id, limit).await;
1759            return Ok((new, warnings));
1760        }
1761        let (community, _) = self.resolve_channel(channel_id)?;
1762        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1763        let mut warnings: Vec<String> = Vec::new();
1764
1765        // FIRST: walk any base (server-root) rotation — a privatize / private-ban rekey advances the
1766        // epoch and re-anchors the control plane under the NEW root, so we must follow it BEFORE reading
1767        // control/messages or we'd look at stale-epoch pseudonyms and silently fall off. No-op (one cheap
1768        // probe) when there's been no rotation. Re-resolve after: the base epoch + root may have advanced.
1769        // An AUTHORIZED base rotation that excluded us (private ban / read-cut) is a removal: erase local
1770        // community data, exactly like an observed banlist/kick. This is the catch-all for a cut member who
1771        // can no longer decrypt the new control plane to read the banlist the normal way (`am_i_banned`).
1772        match service::catch_up_server_root(&transport, &community).await {
1773            Ok(c) if c.removed => {
1774                // ban-rekey exclusion is a self-removal → retain the held epoch keys for later self-scrub.
1775                let _ = crate::db::community::delete_community_retain_keys(&community.id.to_hex());
1776                return Ok((0, warnings));
1777            }
1778            Ok(_) => {}
1779            Err(e) => warnings.push(format!("base catch-up failed: {e}")),
1780        }
1781        let (community, _) = self.resolve_channel(channel_id)?;
1782
1783        // Headless clients have no realtime control-plane subscription, so fold the latest control editions
1784        // here (the desktop does the same on its own latest-page sync). Banlist FIRST: a ban that landed on
1785        // us self-removes like a kick (drop keys + local data, no rejoin). Then roles, the per-creator invite
1786        // links (Public/Private mode), and metadata (name/description/icon/channel-name) — so a rename, role,
1787        // ban, or mode change reaches this member on sync, not just in a realtime client.
1788        if let Err(e) = service::fetch_and_apply_control(&transport, &community).await {
1789            warnings.push(format!("control fold failed: {e}"));
1790        }
1791        if service::am_i_banned(&community) {
1792            // ban self-removal → retain the held epoch keys for later self-scrub.
1793            let _ = crate::db::community::delete_community_retain_keys(&community.id.to_hex());
1794            return Ok((0, warnings));
1795        }
1796        // Walk any CHANNEL rekey so we hold the current channel key before paging it, then re-resolve so the
1797        // batch below carries the fresh channel epoch/key + the freshly-folded banned set + metadata.
1798        let (community, channel) = self.resolve_channel(channel_id)?;
1799        if let Err(e) = service::catch_up_channel_rekeys(&transport, &community, &channel.id).await {
1800            warnings.push(format!("channel catch-up failed: {e}"));
1801        }
1802        // Resume any interrupted re-founding (a privatize/ban whose rotation aborted mid-way — e.g. a
1803        // transient relay miss on the re-anchor). The GUI's sync did this; the agent's path did NOT, so an
1804        // interrupted re-founding stayed `read_cut_pending` forever (channel frozen). Best-effort + surfaced.
1805        let (community, _) = self.resolve_channel(channel_id)?;
1806        if let Err(e) = service::retry_pending_read_cut(&transport, &community).await {
1807            warnings.push(format!("read-cut resume failed: {e}"));
1808        }
1809        let (community, channel) = self.resolve_channel(channel_id)?;
1810
1811        let events = send::fetch_channel_page(&transport, &community, &channel, None, None, limit.max(1))
1812            .await
1813            .map_err(VectorError::Other)?;
1814        let outcomes = {
1815            let mut st = state::STATE.lock().await;
1816            inbound::process_channel_batch(&mut st, &events, &channel, &my_pk)
1817        };
1818        let mut new = 0usize;
1819        for o in &outcomes {
1820            match o {
1821                inbound::IncomingEvent::NewMessage(m) => {
1822                    let _ = crate::db::events::save_message(channel_id, m).await;
1823                    new += 1;
1824                }
1825                inbound::IncomingEvent::Updated { message, .. } => {
1826                    let _ = crate::db::events::save_message(channel_id, message).await;
1827                }
1828                inbound::IncomingEvent::Removed { target_id } => {
1829                    let _ = crate::db::events::delete_event(target_id).await;
1830                }
1831                inbound::IncomingEvent::ReactionRemoved { reaction_id, .. } => {
1832                    // save_message is additive, so a revoked reaction's kind-7 row must be
1833                    // dropped explicitly or it resurrects on reload.
1834                    let _ = crate::db::events::delete_event(reaction_id).await;
1835                }
1836                inbound::IncomingEvent::Presence { npub, joined, event_id, created_at, invited_by, invited_label } => {
1837                    let et = if *joined {
1838                        crate::stored_event::SystemEventType::MemberJoined
1839                    } else {
1840                        crate::stored_event::SystemEventType::MemberLeft
1841                    };
1842                    // attribution persisted in the note: "invited_by[|label]".
1843                    let note = invited_by.as_ref().map(|by| match invited_label {
1844                        Some(l) if !l.is_empty() => format!("{by}|{l}"),
1845                        _ => by.clone(),
1846                    });
1847                    let _ = crate::db::events::save_system_event_at(event_id, channel_id, et, npub, note.as_deref(), *created_at, invited_by.as_deref(), invited_label.as_deref()).await;
1848                }
1849                inbound::IncomingEvent::WebxdcPeer { npub, topic_id, node_addr, event_id, created_at } => {
1850                    // Persist only (DM-parity row) — the miniapp layer bootstraps from the DB at
1851                    // game-open. Live gossip-feed pokes are the realtime subscription's job.
1852                    community::service::persist_webxdc_signal(
1853                        channel_id, npub, topic_id, node_addr.as_deref(), event_id, *created_at,
1854                    ).await;
1855                }
1856                inbound::IncomingEvent::Kicked { community_id }
1857                | inbound::IncomingEvent::SelfLeft { community_id } => {
1858                    // self-removal (kick of me, or a leave I/another device authored): drop the
1859                    // community's local state but RETAIN the held epoch keys (later self-scrub). The core-level
1860                    // half of leaving; a client shell layers on subscription-refresh + chat-row teardown + UI.
1861                    // Stop the batch — the community is gone, so later same-batch writes would orphan rows.
1862                    let _ = crate::db::community::delete_community_retain_keys(community_id);
1863                    break;
1864                }
1865                inbound::IncomingEvent::Typing { .. } => {
1866                    // Realtime-only ephemeral signal; never fetched in a sync batch. No-op.
1867                }
1868            }
1869        }
1870        Ok((new, warnings))
1871    }
1872
1873    /// Observed members of a Community (best-effort: those who've posted or announced a join,
1874    /// minus anyone who's left or is banned). v1 entries are `{npub, last_active}`; a v2 entry
1875    /// is `{npub}` (the Complete Memberlist carries no activity time). Best-effort throughout:
1876    /// a transport failure yields an empty list, never an error.
1877    pub async fn get_community_members(&self, community_id: &str) -> Vec<serde_json::Value> {
1878        use nostr_sdk::prelude::ToBech32;
1879        // v2: the Complete Memberlist (guestbook fold + observed authors − banlist).
1880        match Self::load_v2_if_v2(community_id) {
1881            Ok(Some(community)) => {
1882                let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1883                return crate::community::v2::service::memberlist(&transport, &community)
1884                    .await
1885                    .unwrap_or_default()
1886                    .into_iter()
1887                    .filter_map(|pk| pk.to_bech32().ok())
1888                    .map(|npub| serde_json::json!({ "npub": npub }))
1889                    .collect();
1890            }
1891            Ok(None) => {} // genuinely v1 / unknown — fall through.
1892            // Can't determine the protocol: best-effort empty, never a v1 guess.
1893            Err(_) => return Vec::new(),
1894        }
1895        crate::db::community::community_member_activity(community_id)
1896            .unwrap_or_default()
1897            .into_iter()
1898            .map(|(npub, last_active)| serde_json::json!({ "npub": npub, "last_active": last_active }))
1899            .collect()
1900    }
1901
1902    /// One synchronous v2 follow pass — rekeys first (a base adopt moves the
1903    /// control address), then a control refold on the FRESH state, the same order
1904    /// the live follow worker runs. Returns non-fatal warnings.
1905    async fn v2_inline_follow(id: &crate::community::CommunityId) -> Vec<String> {
1906        use crate::community::transport::LiveTransport;
1907        let session = state::SessionGuard::capture();
1908        // Serialize with the live follow worker: `follow_worker_running` is
1909        // check-then-act, so a worker can spawn right after a caller saw `false` —
1910        // this shared per-community lock is what actually prevents two follows of
1911        // one community interleaving their whole-row saves.
1912        let lock = crate::community::v2::realtime::follow_lock(id);
1913        let _guard = lock.lock().await;
1914        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1915        let mut warnings: Vec<String> = Vec::new();
1916        let Ok(Some(community)) = crate::db::community::load_community_v2(id) else {
1917            warnings.push("v2 community not found".to_string());
1918            return warnings;
1919        };
1920        let cid_hex = crate::simd::hex::bytes_to_hex_32(&id.0);
1921        match crate::community::v2::service::follow_rekeys(&transport, &community, &session).await {
1922            // A tombstone surfaced during catch-up — sealed read-only; stop here.
1923            Ok(f) if f.dissolved => return warnings,
1924            Ok(f) if f.self_removed => {
1925                // An authorized rotation that excluded us IS a removal — but the
1926                // follow straddled awaits, so never delete from a swapped-in DB.
1927                if session.is_valid() {
1928                    let _ = crate::db::community::delete_community(&cid_hex);
1929                }
1930                return warnings;
1931            }
1932            Ok(_) => {}
1933            Err(e) => warnings.push(format!("v2 rekey follow failed: {e}")),
1934        }
1935        if let Ok(Some(fresh)) = crate::db::community::load_community_v2(id) {
1936            match crate::community::v2::service::follow_control(&transport, &fresh, &session).await {
1937                // A control change can reveal rekey work that predates it (a
1938                // just-announced private channel's key crate already sits on its
1939                // rekey plane), so walk the rekeys once more on the fresh state.
1940                Ok(Some(changed)) => {
1941                    if let Err(e) = crate::community::v2::service::follow_rekeys(&transport, &changed, &session).await {
1942                        warnings.push(format!("v2 rekey follow failed: {e}"));
1943                    }
1944                }
1945                Ok(None) => {}
1946                Err(e) => warnings.push(format!("v2 control follow failed: {e}")),
1947            }
1948        }
1949        warnings
1950    }
1951
1952    /// Fetch a v2 channel's recent chat history and PERSIST it into the shared events
1953    /// tables (the same store v1 uses), so `get_messages`/`get_new_messages` backfill for
1954    /// v2 exactly like v1. PAGES backwards until it reaches messages it already holds
1955    /// (bounded), so a reconnecting bot that slept through more than one page of traffic
1956    /// still catches the whole gap instead of only the newest `limit`. Reuses the v2
1957    /// inbound bridge (dedup + STATE aggregate) + the v1 save path. Returns the count of
1958    /// brand-new messages applied. Best-effort: a fetch failure is 0.
1959    async fn v2_backfill_channel(id: &crate::community::CommunityId, channel_id: &str, limit: usize) -> usize {
1960        use crate::community::v2::inbound::{apply_chat_to_state, persist_chat, ChatPersist};
1961        /// Deepest catch-up walk: pages × page-size bounds one reconnect's fetch.
1962        const MAX_BACKFILL_PAGES: usize = 8;
1963        // Guard straddles the fetch: a swap mid-fetch must not persist account A's chat
1964        // into account B's STATE/DB (the message ids are global).
1965        let session = state::SessionGuard::capture();
1966        let Some(my_pk) = state::my_public_key() else { return 0 };
1967        // CORD-02 §9: a dissolved community honors no NEW events — old history reads
1968        // through the explicit paths, but a catch-up sweep must not ingest anything
1969        // authored into the grave.
1970        if crate::db::community::get_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&id.0)).unwrap_or(false) {
1971            return 0;
1972        }
1973        let Ok(Some(community)) = crate::db::community::load_community_v2(id) else { return 0 };
1974        let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
1975        let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1976        let Ok(page) = crate::community::v2::service::fetch_channel_history(
1977            &transport,
1978            &community,
1979            &ch,
1980            limit.max(50),
1981            MAX_BACKFILL_PAGES,
1982            // Keep paging while a page still contains a MESSAGE we don't hold; a page
1983            // whose messages are all known means we've reached our own history. Only
1984            // message kinds get their own rows (reactions/edits fold into their
1985            // targets), so a page with no messages is undecidable — keep paging.
1986            |page| {
1987                let mut saw_message = false;
1988                for f in page {
1989                    if matches!(&f.event, crate::community::v2::chat::ChatEvent::Message { .. }) {
1990                        saw_message = true;
1991                        if !crate::db::events::event_exists(&f.event.opened().rumor_id.to_hex()).unwrap_or(false) {
1992                            return true;
1993                        }
1994                    }
1995                }
1996                !saw_message
1997            },
1998        )
1999        .await
2000        else {
2001            return 0;
2002        };
2003        let mut new = 0usize;
2004        for f in &page {
2005            // Re-check every iteration — each persists a DB write, and a swap can land
2006            // between them.
2007            if !session.is_valid() {
2008                break;
2009            }
2010            // STATE mutation under the lock; the async DB persist after it drops.
2011            let outcome = {
2012                let mut st = state::STATE.lock().await;
2013                apply_chat_to_state(&mut st, &f.event, channel_id, &my_pk)
2014            };
2015            if let Some(outcome) = outcome {
2016                if matches!(outcome, ChatPersist::New(_)) {
2017                    new += 1;
2018                }
2019                persist_chat(channel_id, &outcome).await;
2020            }
2021        }
2022        new
2023    }
2024
2025    /// The held v2 community when `community_id` names one; `Ok(None)` for v1 (or
2026    /// unknown). A DB read error PROPAGATES (fail-closed) instead of falling open
2027    /// to the v1 route on a transient failure.
2028    fn load_v2_if_v2(community_id: &str) -> Result<Option<crate::community::v2::community::CommunityV2>> {
2029        if community_id.len() != 64 {
2030            return Ok(None);
2031        }
2032        let cid = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
2033        match crate::db::community::community_protocol(&cid).map_err(VectorError::Other)? {
2034            Some(crate::community::ConcordProtocol::V2) => crate::db::community::load_community_v2(&cid).map_err(VectorError::Other),
2035            _ => Ok(None),
2036        }
2037    }
2038
2039    // ── Community admin actions ── role-gated; vector-core re-checks authority on every action and peers
2040    // re-verify against the owner-rooted roster, so these can't forge standing. A bunker account can't ban
2041    // in a private community (the rekey needs a raw local key).
2042
2043    fn load_community_hex(community_id: &str) -> Result<crate::community::Community> {
2044        use crate::community::CommunityId;
2045        if community_id.len() != 64 {
2046            return Err(VectorError::Other("malformed community id".into()));
2047        }
2048        crate::db::community::load_community(&CommunityId(crate::simd::hex::hex_to_bytes_32(community_id)))
2049            .map_err(VectorError::Other)?
2050            .ok_or_else(|| VectorError::Other("community not found".into()))
2051    }
2052
2053    fn admin_role_id_of(community_id: &str) -> Result<String> {
2054        let roles = crate::db::community::get_community_roles(community_id).map_err(VectorError::Other)?;
2055        roles.roles.iter()
2056            .find(|r| matches!(r.scope, crate::community::roles::RoleScope::Server)
2057                && r.permissions.contains(crate::community::roles::Permissions::ADMIN_ALL))
2058            .map(|r| r.role_id.clone())
2059            .ok_or_else(|| VectorError::Other("admin role not found (roster not synced?)".into()))
2060    }
2061
2062    /// My effective management capabilities in a community (role engine — owner is just position 0). Use to
2063    /// confirm a promotion/demotion landed. A local read: the roster is folded + persisted by the passive
2064    /// sync (v1) / control follow (v2), never fetched here.
2065    pub fn community_capabilities(&self, community_id: &str) -> Result<serde_json::Value> {
2066        use crate::community::service;
2067        if let Some(v2) = Self::load_v2_if_v2(community_id)? {
2068            use crate::community::roles::Permissions;
2069            let me = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?.to_hex();
2070            let owner_hex = v2.owner().map_err(VectorError::Other)?.to_hex();
2071            let roster = crate::db::community::get_community_roles(community_id).map_err(VectorError::Other)?;
2072            // A banned member holds no standing (CORD-04 §4), even if a since-skipped
2073            // roster persist still lists their grant — the banlist advances on its own gate.
2074            let banned = crate::db::community::get_community_banlist(community_id).unwrap_or_default();
2075            if banned.contains(&me) && me != owner_hex {
2076                return Ok(serde_json::json!({
2077                    "manage_metadata": false, "manage_channels": false, "create_invite": false, "kick": false,
2078                    "ban": false, "manage_messages": false, "manage_roles": false, "manage_admin_role": false,
2079                }));
2080            }
2081            let has = |p: u64| roster.is_authorized(&me, Some(&owner_hex), p);
2082            return Ok(serde_json::json!({
2083                "manage_metadata": has(Permissions::MANAGE_METADATA), "manage_channels": has(Permissions::MANAGE_CHANNELS),
2084                "create_invite": has(Permissions::CREATE_INVITE), "kick": has(Permissions::KICK), "ban": has(Permissions::BAN),
2085                "manage_messages": has(Permissions::MANAGE_MESSAGES), "manage_roles": has(Permissions::MANAGE_ROLES),
2086                // Only the owner (position 0) strictly outranks the position-1 Admin role.
2087                "manage_admin_role": me == owner_hex,
2088            }));
2089        }
2090        let community = Self::load_community_hex(community_id)?;
2091        let caps = service::caller_capabilities(&community);
2092        let manage_admin_role = Self::admin_role_id_of(community_id).ok()
2093            .map(|rid| service::caller_can_manage_role_id(&community, &rid))
2094            .unwrap_or(false);
2095        Ok(serde_json::json!({
2096            "manage_metadata": caps.manage_metadata, "manage_channels": caps.manage_channels,
2097            "create_invite": caps.create_invite, "kick": caps.kick, "ban": caps.ban,
2098            "manage_messages": caps.manage_messages, "manage_roles": caps.manage_roles,
2099            "manage_admin_role": manage_admin_role,
2100        }))
2101    }
2102
2103    /// The community's owner npub + the admin npubs (role overview). A local read,
2104    /// like [`Self::community_capabilities`].
2105    pub fn community_roles(&self, community_id: &str) -> Result<serde_json::Value> {
2106        use nostr_sdk::prelude::{PublicKey, ToBech32};
2107        if let Some(v2) = Self::load_v2_if_v2(community_id)? {
2108            let owner = v2.owner().map_err(VectorError::Other)?;
2109            let roster = crate::db::community::get_community_roles(community_id).map_err(VectorError::Other)?;
2110            // Exclude banned members from the admin list (a banned npub vanishes, §4).
2111            let banned = crate::db::community::get_community_banlist(community_id).unwrap_or_default();
2112            let admins: Vec<String> = roster.grants.iter()
2113                .filter(|g| roster.is_admin(&g.member) && !banned.contains(&g.member))
2114                .filter_map(|g| PublicKey::from_hex(&g.member).ok().and_then(|pk| pk.to_bech32().ok()))
2115                .collect();
2116            return Ok(serde_json::json!({ "owner": owner.to_bech32().ok(), "admins": admins }));
2117        }
2118        let community = Self::load_community_hex(community_id)?;
2119        let owner = community.owner_attestation.as_ref()
2120            .and_then(|att| crate::community::owner::verify_owner_attestation(att, &community.id.to_hex()))
2121            .and_then(|pk| ToBech32::to_bech32(&pk).ok());
2122        let roles = crate::db::community::get_community_roles(community_id).map_err(VectorError::Other)?;
2123        let admins: Vec<String> = roles.grants.iter().filter(|g| roles.is_admin(&g.member))
2124            .filter_map(|g| PublicKey::from_hex(&g.member).ok().and_then(|pk| pk.to_bech32().ok()))
2125            .collect();
2126        Ok(serde_json::json!({ "owner": owner, "admins": admins }))
2127    }
2128
2129    /// Grant a member the @admin role. Requires MANAGE_ROLES + outranking the role's position.
2130    pub async fn grant_admin(&self, community_id: &str, npub: &str) -> Result<()> {
2131        use crate::community::{service, transport::LiveTransport};
2132        let member = nostr_sdk::prelude::PublicKey::parse(npub).map_err(|_| VectorError::Other("invalid npub".into()))?;
2133        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2134        if let Some(v2) = Self::load_v2_if_v2(community_id)? {
2135            return crate::community::v2::service::grant_admin(&transport, &v2, &member)
2136                .await
2137                .map_err(VectorError::Other);
2138        }
2139        let community = Self::load_community_hex(community_id)?;
2140        let role_id = Self::admin_role_id_of(community_id)?;
2141        service::grant_role(&transport, &community, member, &role_id).await.map_err(VectorError::Other)
2142    }
2143
2144    /// Revoke a member's @admin role.
2145    pub async fn revoke_admin(&self, community_id: &str, npub: &str) -> Result<()> {
2146        use crate::community::{service, transport::LiveTransport};
2147        let member = nostr_sdk::prelude::PublicKey::parse(npub).map_err(|_| VectorError::Other("invalid npub".into()))?;
2148        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2149        if let Some(v2) = Self::load_v2_if_v2(community_id)? {
2150            return crate::community::v2::service::revoke_admin(&transport, &v2, &member)
2151                .await
2152                .map_err(VectorError::Other);
2153        }
2154        let community = Self::load_community_hex(community_id)?;
2155        let role_id = Self::admin_role_id_of(community_id)?;
2156        service::revoke_role(&transport, &community, member, &role_id).await.map_err(VectorError::Other)
2157    }
2158
2159    /// Cooperatively kick a member — they self-remove but can rejoin. Requires KICK + outrank.
2160    pub async fn kick_member(&self, community_id: &str, npub: &str) -> Result<()> {
2161        use crate::community::{service, transport::LiveTransport};
2162        let pk = nostr_sdk::prelude::PublicKey::parse(npub).map_err(|_| VectorError::Other("invalid npub".into()))?;
2163        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2164        if let Some(v2) = Self::load_v2_if_v2(community_id)? {
2165            return crate::community::v2::service::kick_member(&transport, &v2, &pk)
2166                .await
2167                .map_err(VectorError::Other);
2168        }
2169        let community = Self::load_community_hex(community_id)?;
2170        let channel = community.channels.first().ok_or_else(|| VectorError::Other("community has no channel".into()))?;
2171        service::publish_kick(&transport, &community, channel, &pk.to_hex()).await.map(|_| ()).map_err(VectorError::Other)
2172    }
2173
2174    /// Ban (`true`) or unban (`false`) a member. Ban is terminal (no rejoin); in a private community it also
2175    /// fires the read-cut rekey (needs a local key). Requires BAN + outrank.
2176    pub async fn set_member_banned(&self, community_id: &str, npub: &str, banned: bool) -> Result<()> {
2177        use crate::community::{service, transport::LiveTransport, CommunityId};
2178        let pk = nostr_sdk::prelude::PublicKey::parse(npub).map_err(|_| VectorError::Other("invalid npub".into()))?;
2179        let hex = pk.to_hex();
2180        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2181        // Recompute the full list (latest-wins): drop any existing entry, then add if banning.
2182        let mut list = crate::db::community::get_community_banlist(community_id).map_err(VectorError::Other)?;
2183        list.retain(|h| h != &hex);
2184        if banned {
2185            list.push(hex);
2186        }
2187        // Dual-stack: a v2 Ban is the CORD-04 §6 three-removal composition, in order —
2188        // the Banlist edition first (instant silence), then the Grant strip (authority
2189        // removal), then the Refounding read-cut (cryptographic severance).
2190        if community_id.len() == 64 {
2191            let cid = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
2192            if let Some(Some(crate::community::ConcordProtocol::V2)) = crate::db::community::community_protocol(&cid).ok() {
2193                let community = crate::db::community::load_community_v2(&cid)
2194                    .map_err(VectorError::Other)?
2195                    .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
2196                crate::community::v2::service::set_banlist(&transport, &community, &list).await.map_err(VectorError::Other)?;
2197                if banned {
2198                    crate::community::v2::service::grant_roles(&transport, &community, &pk, vec![]).await.map_err(VectorError::Other)?;
2199                    crate::community::v2::service::refound_community(&transport, &community, &[pk]).await.map_err(VectorError::Other)?;
2200                }
2201                return Ok(());
2202            }
2203        }
2204        let community = Self::load_community_hex(community_id)?;
2205        service::publish_banlist(&transport, &community, &list).await.map_err(VectorError::Other)
2206    }
2207
2208    /// Owner dissolution / "Delete Community": publish the terminal GroupDissolved tombstone (and
2209    /// retire the owner's own invite links, no rekey), sealing the community permanently. Owner-only
2210    /// (re-verified cryptographically in `service::dissolve_community`); irreversible.
2211    pub async fn dissolve_community(&self, community_id: &str) -> Result<()> {
2212        use crate::community::{service, transport::LiveTransport, CommunityId};
2213        if community_id.len() != 64 {
2214            return Err(VectorError::Other("malformed community id".into()));
2215        }
2216        let cid = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
2217        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2218        // Dual-stack: a v2 community dissolves at its own `community_id`-derived
2219        // dissolved plane (CORD-02 §9), NOT v1's control-plane roster edition.
2220        if let Some(Some(crate::community::ConcordProtocol::V2)) = crate::db::community::community_protocol(&cid).ok() {
2221            let community = crate::db::community::load_community_v2(&cid)
2222                .map_err(VectorError::Other)?
2223                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
2224            return crate::community::v2::service::dissolve_community(&transport, &community)
2225                .await
2226                .map_err(VectorError::Other);
2227        }
2228        let community = Self::load_community_hex(community_id)?;
2229        service::dissolve_community(&transport, &community).await.map_err(VectorError::Other)
2230    }
2231
2232    /// Edit community metadata (name / description) as an authorized member (MANAGE_METADATA). `None` leaves
2233    /// a field unchanged; an empty description clears it.
2234    pub async fn edit_community_metadata(&self, community_id: &str, name: Option<&str>, description: Option<&str>) -> Result<()> {
2235        use crate::community::{service, transport::LiveTransport, CommunityId};
2236        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2237        // Dual-stack: a v2 metadata edit is an authorized vsk-0 control edition. (Full
2238        // icon/custom round-trip preservation rides with the GUI wiring; a bot edit
2239        // sets name/description/relays from the held community.)
2240        if community_id.len() == 64 {
2241            let cid = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
2242            if let Some(Some(crate::community::ConcordProtocol::V2)) = crate::db::community::community_protocol(&cid).ok() {
2243                let community = crate::db::community::load_community_v2(&cid)
2244                    .map_err(VectorError::Other)?
2245                    .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
2246                let meta = crate::community::v2::control::CommunityMetadata {
2247                    name: name.unwrap_or(&community.name).to_string(),
2248                    description: match description {
2249                        Some("") => None,
2250                        Some(d) => Some(d.to_string()),
2251                        None => community.description.clone(),
2252                    },
2253                    relays: community.relays.clone(),
2254                    ..Default::default()
2255                };
2256                return crate::community::v2::service::edit_community_metadata(&transport, &community, &meta)
2257                    .await
2258                    .map_err(VectorError::Other);
2259            }
2260        }
2261        let mut community = Self::load_community_hex(community_id)?;
2262        if let Some(n) = name { community.name = n.to_string(); }
2263        if let Some(d) = description { community.description = if d.is_empty() { None } else { Some(d.to_string()) }; }
2264        service::republish_community_metadata(&transport, &community).await.map_err(VectorError::Other)
2265    }
2266
2267    /// Create a new channel in a v2 community. A PUBLIC channel derives from the
2268    /// community_root, so peers fold it in with nothing to distribute; a PRIVATE one
2269    /// mints an independent key at channel-epoch 1 and delivers it to every current
2270    /// member over the rekey plane (CORD-03 §2 / CORD-06). Requires MANAGE_CHANNELS.
2271    /// Returns the new channel id (hex).
2272    pub async fn create_community_channel(&self, community_id: &str, name: &str, private: bool) -> Result<String> {
2273        let v2 = Self::load_v2_if_v2(community_id)?
2274            .ok_or_else(|| VectorError::Other("channel creation is available on v2 communities".into()))?;
2275        let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2276        let id = if private {
2277            crate::community::v2::service::create_private_channel(&transport, &v2, name).await
2278        } else {
2279            crate::community::v2::service::create_public_channel(&transport, &v2, name).await
2280        }
2281        .map_err(VectorError::Other)?;
2282        // Subscribe the new channel's chat plane now — waiting on the round-trip of
2283        // our own vsk-2 edition would leave the creator deaf to first replies.
2284        if let Some(client) = state::nostr_client() {
2285            crate::community::v2::realtime::refresh_subscription(&client).await;
2286        }
2287        Ok(crate::simd::hex::bytes_to_hex_32(&id.0))
2288    }
2289
2290    /// Delete (tombstone) a v2 community channel. Requires MANAGE_CHANNELS (reader-gated).
2291    pub async fn delete_community_channel(&self, community_id: &str, channel_id: &str) -> Result<()> {
2292        let v2 = Self::load_v2_if_v2(community_id)?
2293            .ok_or_else(|| VectorError::Other("channel deletion is available on v2 communities".into()))?;
2294        let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
2295        let name = v2.channels.iter().find(|c| c.id.0 == ch.0).map(|c| c.name.clone()).unwrap_or_default();
2296        let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2297        crate::community::v2::service::delete_channel(&transport, &v2, &ch, &name)
2298            .await
2299            .map_err(VectorError::Other)
2300    }
2301
2302    /// Leave a Community: announce a best-effort "left" presence (before dropping keys), then
2303    /// drop the held keys + local channel chats. You need a fresh invite to rejoin.
2304    pub async fn leave_community(&self, community_id: &str) -> Result<()> {
2305        use crate::community::{transport::LiveTransport, CommunityId};
2306        if community_id.len() != 64 {
2307            return Err(VectorError::Other("malformed community id".into()));
2308        }
2309        let id = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
2310        // v2: guestbook Leave + cross-device List tombstone + local delete, in the service.
2311        if let Some(v2) = Self::load_v2_if_v2(community_id)? {
2312            let session = state::SessionGuard::capture();
2313            let channel_ids: Vec<String> =
2314                v2.channels.iter().map(|ch| crate::simd::hex::bytes_to_hex_32(&ch.id.0)).collect();
2315            let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2316            crate::community::v2::service::leave_community(&transport, &v2)
2317                .await
2318                .map_err(VectorError::Other)?;
2319            if !session.is_valid() {
2320                return Err(VectorError::Other("account changed during leave".into()));
2321            }
2322            let mut st = state::STATE.lock().await;
2323            st.chats.retain(|c| !channel_ids.contains(&c.id));
2324            return Ok(());
2325        }
2326        let community = crate::db::community::load_community(&id).map_err(VectorError::Other)?;
2327        let channel_ids: Vec<String> = community
2328            .as_ref()
2329            .map(|c| c.channels.iter().map(|ch| ch.id.to_hex()).collect())
2330            .unwrap_or_default();
2331        // "Left" announcement BEFORE dropping keys (afterward we can't sign/seal into the channel).
2332        if let Some(ref c) = community {
2333            if let Some(primary) = c.channels.first() {
2334                let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2335                let _ = crate::community::service::publish_presence(&transport, c, primary, false, None).await;
2336            }
2337        }
2338        // voluntary leave is a self-removal → retain the held epoch keys for later self-scrub.
2339        crate::db::community::delete_community_retain_keys(community_id).map_err(VectorError::Other)?;
2340        {
2341            let mut st = state::STATE.lock().await;
2342            st.chats.retain(|c| !channel_ids.contains(&c.id));
2343        }
2344        Ok(())
2345    }
2346
2347    /// Resolve a channel id to its owning Community + the Channel (with its secret key).
2348    fn resolve_channel(
2349        &self,
2350        channel_id: &str,
2351    ) -> Result<(crate::community::Community, crate::community::Channel)> {
2352        use crate::community::CommunityId;
2353        let community_id = crate::db::community::community_id_for_channel(channel_id)
2354            .map_err(VectorError::Other)?
2355            .ok_or_else(|| VectorError::Other("Unknown Community channel".into()))?;
2356        if community_id.len() != 64 {
2357            return Err(VectorError::Other("malformed community id".into()));
2358        }
2359        let community = crate::db::community::load_community(&CommunityId(
2360            crate::simd::hex::hex_to_bytes_32(&community_id),
2361        ))
2362        .map_err(VectorError::Other)?
2363        .ok_or_else(|| VectorError::Other("Community not found".into()))?;
2364        let channel = community
2365            .channels
2366            .iter()
2367            .find(|c| c.id.to_hex() == channel_id)
2368            .cloned()
2369            .ok_or_else(|| VectorError::Other("Channel not found in Community".into()))?;
2370        Ok((community, channel))
2371    }
2372
2373
2374    /// Sync DM history from relays using NIP-77 negentropy set reconciliation.
2375    ///
2376    /// Reconciles local wrapper history with relay state, fetches missing events,
2377    /// and processes them through the standard prepare → commit pipeline.
2378    ///
2379    /// Returns (total_events, new_messages).
2380    ///
2381    /// ```no_run
2382    /// # async fn example() -> vector_core::Result<()> {
2383    /// let core = vector_core::VectorCore;
2384    /// // Sync last 7 days of DMs
2385    /// let (events, new) = core.sync_dms(Some(7), &vector_core::NoOpEventHandler).await?;
2386    /// println!("Processed {} events, {} new messages", events, new);
2387    /// # Ok(())
2388    /// # }
2389    /// ```
2390    pub async fn sync_dms(
2391        &self,
2392        since_days: Option<u64>,
2393        handler: &dyn InboundEventHandler,
2394    ) -> Result<(u32, u32)> {
2395        use futures_util::StreamExt;
2396        use nostr_sdk::prelude::*;
2397
2398        let client = state::nostr_client()
2399            .ok_or(VectorError::Other("Not connected".into()))?;
2400        let my_pk = state::my_public_key()
2401            .ok_or(VectorError::Other("Not logged in".into()))?;
2402
2403        // Load known wrapper IDs + timestamps for negentropy fingerprinting
2404        let all_items = db::wrappers::load_negentropy_items().unwrap_or_default();
2405
2406        // Filter items to time window (or use all for full sync)
2407        let (items, filter) = if let Some(days) = since_days {
2408            let since_ts = Timestamp::now().as_secs().saturating_sub(days * 24 * 3600);
2409            let items: Vec<(EventId, Timestamp)> = all_items.iter()
2410                .filter(|(_, ts)| ts.as_secs() >= since_ts)
2411                .cloned()
2412                .collect();
2413            let filter = Filter::new()
2414                .pubkey(my_pk)
2415                .kind(Kind::GiftWrap)
2416                .since(Timestamp::from_secs(since_ts));
2417            (items, filter)
2418        } else {
2419            let filter = Filter::new()
2420                .pubkey(my_pk)
2421                .kind(Kind::GiftWrap);
2422            (all_items, filter)
2423        };
2424
2425        log_info!("[SyncDMs] {} negentropy items, since_days={:?}", items.len(), since_days);
2426
2427        // Dry-run negentropy: exchange fingerprints to identify missing events
2428        let sync_opts = nostr_sdk::SyncOptions::new()
2429            .direction(nostr_sdk::SyncDirection::Down)
2430            .initial_timeout(std::time::Duration::from_secs(10))
2431            .dry_run();
2432
2433        // Race all relays — first to reconcile drives the fetch
2434        let relay_map = client.relays().await;
2435        let all_relays: Vec<(RelayUrl, Relay)> = relay_map.iter()
2436            .map(|(url, relay)| (url.clone(), relay.clone()))
2437            .collect();
2438        drop(relay_map);
2439
2440        let mut relay_futs = futures_util::stream::FuturesUnordered::new();
2441        for (url, relay) in &all_relays {
2442            let url = url.clone();
2443            let relay = relay.clone();
2444            let f = filter.clone();
2445            let i = items.clone();
2446            let o = sync_opts.clone();
2447            relay_futs.push(async move {
2448                let result = tokio::time::timeout(
2449                    std::time::Duration::from_secs(10),
2450                    relay.sync_with_items(f, i, &o),
2451                ).await;
2452                (url, result)
2453            });
2454        }
2455
2456        // Collect missing IDs from all relays
2457        let mut all_missing: std::collections::HashSet<EventId> = std::collections::HashSet::new();
2458        while let Some((url, result)) = relay_futs.next().await {
2459            match result {
2460                Ok(Ok(recon)) => {
2461                    let count = recon.remote.len();
2462                    all_missing.extend(recon.remote);
2463                    log_info!("[SyncDMs] {} reconciled: {} missing", url, count);
2464                }
2465                Ok(Err(e)) => log_warn!("[SyncDMs] {} failed: {}", url, e),
2466                Err(_) => log_warn!("[SyncDMs] {} timed out (10s)", url),
2467            }
2468        }
2469
2470        if all_missing.is_empty() {
2471            log_info!("[SyncDMs] No missing events");
2472            return Ok((0, 0));
2473        }
2474
2475        // Fetch missing events in batches
2476        log_info!("[SyncDMs] Fetching {} missing events", all_missing.len());
2477        let ids: Vec<EventId> = all_missing.into_iter().collect();
2478        let relay_strs: Vec<String> = client.relays().await.keys()
2479            .map(|u| u.to_string()).collect();
2480
2481        let mut total_events = 0u32;
2482        let mut new_messages = 0u32;
2483        const BATCH_SIZE: usize = 500;
2484
2485        for batch in ids.chunks(BATCH_SIZE) {
2486            let f = Filter::new().ids(batch.to_vec()).kind(Kind::GiftWrap);
2487            match client.stream_events_from(
2488                relay_strs.clone(), f,
2489                std::time::Duration::from_secs(30),
2490            ).await {
2491                Ok(stream) => {
2492                    let client_clone = client.clone();
2493                    let prepared_stream = stream
2494                        .map(move |event| {
2495                            let c = client_clone.clone();
2496                            tokio::spawn(async move {
2497                                event_handler::prepare_event(event, &c, my_pk).await
2498                            })
2499                        })
2500                        .buffer_unordered(8);
2501                    tokio::pin!(prepared_stream);
2502
2503                    while let Some(result) = prepared_stream.next().await {
2504                        total_events += 1;
2505                        if let Ok(prepared) = result {
2506                            if event_handler::commit_prepared_event(prepared, false, handler).await {
2507                                new_messages += 1;
2508                            }
2509                        }
2510                    }
2511                }
2512                Err(e) => log_warn!("[SyncDMs] Batch fetch error: {}", e),
2513            }
2514        }
2515
2516        log_info!("[SyncDMs] Complete: {} events processed, {} new messages", total_events, new_messages);
2517        Ok((total_events, new_messages))
2518    }
2519
2520    // ========================================================================
2521    // Event Subscription
2522    // ========================================================================
2523
2524    /// Subscribe to incoming DM events (NIP-17 GiftWraps).
2525    ///
2526    /// Returns the subscription ID for use in a custom notification loop.
2527    /// For a complete listen-and-process loop, use [`listen()`](Self::listen) instead.
2528    pub async fn subscribe_dms(&self) -> Result<nostr_sdk::SubscriptionId> {
2529        use nostr_sdk::prelude::*;
2530        let client = state::nostr_client()
2531            .ok_or(VectorError::Other("Not connected".into()))?;
2532        let my_pk = state::my_public_key()
2533            .ok_or(VectorError::Other("Not logged in".into()))?;
2534
2535        let filter = Filter::new()
2536            .pubkey(my_pk)
2537            .kind(Kind::GiftWrap)
2538            .limit(0);
2539
2540        let output = client.subscribe(filter, None).await
2541            .map_err(|e| VectorError::Nostr(e.to_string()))?;
2542        Ok(output.val)
2543    }
2544
2545    /// Catch up every locally-held Community: fold control / re-foundings / rekeys / banlist and
2546    /// fetch recent messages into local state for each channel. State-only (does not replay to an
2547    /// [`InboundEventHandler`]). Called at `listen()` start and periodically for outage resilience;
2548    /// also safe to call manually after a known disconnect.
2549    ///
2550    /// Catch up every locally-held Community. v1 channels are synced inline; a v2
2551    /// community is ENQUEUED for the follow worker (control/rekey re-fold + adopt),
2552    /// non-blocking. State-only (no handler replay of history). Called at `listen()`
2553    /// start and on reconnect; safe to call manually — the v2 enqueue is a no-op if
2554    /// no `listen()` worker is running.
2555    pub async fn sync_communities(&self) -> Result<()> {
2556        // Discover + rehydrate memberships from the 13302 across devices (CORD-02 §8),
2557        // bootstrapping from the client's connected relays so even a fresh device that
2558        // holds no community yet can find them. Best-effort.
2559        {
2560            use crate::community::{transport::LiveTransport, v2::service as v2};
2561            let bootstrap: Vec<String> = match crate::state::nostr_client() {
2562                Some(client) => client.relays().await.keys().map(|r| r.to_string()).collect(),
2563                None => Vec::new(),
2564            };
2565            let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2566            if let Ok(joined) = v2::sync_community_list(&transport, &bootstrap).await {
2567                for c in &joined {
2568                    if community::v2::realtime::follow_worker_running() {
2569                        community::v2::realtime::enqueue_follow(c.id());
2570                    } else {
2571                        let _ = Self::v2_inline_follow(c.id()).await;
2572                    }
2573                }
2574                if !joined.is_empty() {
2575                    if let Some(client) = crate::state::nostr_client() {
2576                        community::v2::realtime::refresh_subscription(&client).await;
2577                    }
2578                }
2579            }
2580        }
2581
2582        let ids = db::community::list_community_ids().map_err(VectorError::from)?;
2583        for id in ids {
2584            if matches!(db::community::community_protocol(&id).ok().flatten(), Some(crate::community::ConcordProtocol::V2)) {
2585                // With a live listen() the coalescing worker owns the follow; headless
2586                // (no worker) it would be dropped, so walk it inline instead.
2587                if community::v2::realtime::follow_worker_running() {
2588                    community::v2::realtime::enqueue_follow(&id);
2589                } else {
2590                    let _ = Self::v2_inline_follow(&id).await;
2591                }
2592                continue;
2593            }
2594            if let Ok(Some(community)) = db::community::load_community(&id) {
2595                for ch in &community.channels {
2596                    let _ = self.sync_community_channel(&ch.id.to_hex(), 50).await;
2597                }
2598            }
2599        }
2600        Ok(())
2601    }
2602
2603
2604    /// Start listening for incoming DMs.
2605    ///
2606    /// Blocks until the client disconnects. Processes GiftWraps
2607    /// (DMs, files) → prepare_event → commit_prepared_event.
2608    ///
2609    /// ```no_run
2610    /// use vector_core::*;
2611    /// use std::sync::Arc;
2612    ///
2613    /// struct MyBot;
2614    /// impl InboundEventHandler for MyBot {
2615    ///     fn on_dm_received(&self, chat_id: &str, msg: &Message, _is_new: bool) {
2616    ///         if msg.mine { return; }
2617    ///         let to = chat_id.to_string();
2618    ///         let reply = format!("Echo: {}", msg.content);
2619    ///         tokio::spawn(async move {
2620    ///             let _ = VectorCore.send_dm(&to, &reply).await;
2621    ///         });
2622    ///     }
2623    /// }
2624    ///
2625    /// # async fn example() -> vector_core::Result<()> {
2626    /// let core = VectorCore::init(CoreConfig {
2627    ///     data_dir: "/tmp/bot-data".into(),
2628    ///     event_emitter: None,
2629    /// })?;
2630    /// core.login("nsec1...", None).await?;
2631    /// core.listen(Arc::new(MyBot)).await?;
2632    /// # Ok(())
2633    /// # }
2634    /// ```
2635    pub async fn listen(&self, handler: Arc<dyn InboundEventHandler>) -> Result<()> {
2636        use nostr_sdk::prelude::*;
2637
2638        let client = state::nostr_client()
2639            .ok_or(VectorError::Other("Not connected".into()))?;
2640        let my_pk = state::my_public_key()
2641            .ok_or(VectorError::Other("Not logged in".into()))?;
2642
2643        // Start the stream-AUTH responder BEFORE any relay interaction: a gating
2644        // relay issues its NIP-42 challenge ONCE per connection, and the DM
2645        // subscribe below consumes it via nostr-sdk's user auto-auth — if the
2646        // responder isn't already watching, that challenge is never remembered
2647        // and the stream keys registered later can NEVER authenticate (the relay
2648        // won't re-challenge an authed connection; the v2 sub dies silently).
2649        community::v2::streamauth::ensure_responder(&client);
2650
2651        // Outage resilience — catch up on connect, then re-sync periodically.
2652        //
2653        // Catch up BEFORE going realtime so a bot that was offline folds any missed re-foundings /
2654        // metadata / banlist changes (and recent messages) into local state, and subscribes at the
2655        // CURRENT epoch pseudonyms. This is state-only: historical messages are not replayed to the
2656        // handler (matches the gateway model) — query them via `get_messages`.
2657        // Spawn the single per-community follow worker for this session; the v2
2658        // follow queue (fed by dispatch, catch-up, and sync) drains through it.
2659        community::v2::realtime::spawn_follow_worker(handler.clone());
2660        let _ = self.sync_communities().await;
2661        let _ = self.sync_dms(None, &NoOpEventHandler).await;
2662
2663        // Subscribe to DMs (GiftWraps) AND Community channel events — one loop dispatches both
2664        // through the same handler, so `on_dm_received`/`on_community_message` share a sink.
2665        let dm_sub_id = self.subscribe_dms().await?;
2666        community::realtime::refresh_subscription(&client).await;
2667        community::v2::realtime::refresh_subscription(&client).await;
2668
2669        // Outage resilience via the relay Monitor — event-driven, not polling.
2670        //
2671        // (1) Reconnect-driven catch-up: a `limit(0)` realtime sub never replays what was published
2672        // while we were down, so a relay (re)connecting is exactly when we must catch up. On each
2673        // Connected transition we refold consensus + reconcile DMs (NIP-77 negentropy → only the
2674        // diff) and re-track the realtime sub at the current epochs. Idle when healthy. Stops on swap.
2675        if let Some(monitor) = client.monitor() {
2676            let mut rx = monitor.subscribe();
2677            let session = state::SessionGuard::capture();
2678            tokio::spawn(async move {
2679                // Debounce reconnect bursts: StatusChanged is per-relay, but one catch-up queries the
2680                // whole pool — so coalesce Connected transitions within a short window into one resync.
2681                let mut last_resync: Option<std::time::Instant> = None;
2682                while let Ok(notification) = rx.recv().await {
2683                    if !session.is_valid() {
2684                        return;
2685                    }
2686                    let MonitorNotification::StatusChanged { status, .. } = notification;
2687                    if status == RelayStatus::Connected {
2688                        if last_resync.is_some_and(|t| t.elapsed() < std::time::Duration::from_secs(3)) {
2689                            continue;
2690                        }
2691                        let _ = VectorCore.sync_communities().await;
2692                        let _ = VectorCore.sync_dms(None, &NoOpEventHandler).await;
2693                        if let Some(c) = state::nostr_client() {
2694                            community::realtime::refresh_subscription(&c).await;
2695                            community::v2::realtime::refresh_subscription(&c).await;
2696                        }
2697                        last_resync = Some(std::time::Instant::now());
2698                    }
2699                }
2700            });
2701        }
2702
2703        // (2) Health probe: a relay can report Connected while silently dead. Every 60s probe each
2704        // with a tiny query + timeout; a zombie is force-reconnected (which fires the monitor above
2705        // → catch-up), and Disconnected/Terminated relays are reconnected directly.
2706        {
2707            let client_health = client.clone();
2708            let session = state::SessionGuard::capture();
2709            tokio::spawn(async move {
2710                tokio::time::sleep(std::time::Duration::from_secs(30)).await; // warm-up
2711                loop {
2712                    if !session.is_valid() {
2713                        return;
2714                    }
2715                    for (url, relay) in client_health.relays().await {
2716                        match relay.status() {
2717                            RelayStatus::Connected => {
2718                                let probe = tokio::time::timeout(
2719                                    std::time::Duration::from_secs(10),
2720                                    client_health.fetch_events_from(
2721                                        vec![url.to_string()],
2722                                        Filter::new().kind(Kind::Metadata).limit(1),
2723                                        std::time::Duration::from_secs(8),
2724                                    ),
2725                                )
2726                                .await;
2727                                if !matches!(probe, Ok(Ok(_))) {
2728                                    let _ = relay.disconnect();
2729                                    tokio::time::sleep(std::time::Duration::from_millis(500)).await;
2730                                    let _ = relay.try_connect(std::time::Duration::from_secs(10)).await;
2731                                }
2732                            }
2733                            RelayStatus::Terminated | RelayStatus::Disconnected => {
2734                                let _ = relay.try_connect(std::time::Duration::from_secs(10)).await;
2735                            }
2736                            _ => {}
2737                        }
2738                    }
2739                    tokio::time::sleep(std::time::Duration::from_secs(60)).await;
2740                }
2741            });
2742        }
2743
2744        let client_for_closure = client.clone();
2745
2746        client.handle_notifications(move |notification| {
2747            let handler = handler.clone();
2748            let c = client_for_closure.clone();
2749            let dm_sid = dm_sub_id.clone();
2750            async move {
2751                if let RelayPoolNotification::Event { event, subscription_id, .. } = notification {
2752                    if subscription_id == dm_sid {
2753                        // DMs, files, reactions
2754                        let prepared = event_handler::prepare_event(*event, &c, my_pk).await;
2755                        event_handler::commit_prepared_event(prepared, true, &*handler).await;
2756                    } else if community::realtime::subscription_id().await.as_ref() == Some(&subscription_id)
2757                        || community::realtime::poolwide_subscription_id().await.as_ref() == Some(&subscription_id)
2758                    {
2759                        // Community (v1) channel messages / reactions / edits / control editions.
2760                        // OR the pool-wide sub (the path that streams on Android) — else v1 events
2761                        // arriving under it match no branch and are silently dropped.
2762                        let session = state::SessionGuard::capture();
2763                        community::realtime::dispatch_event(&session, *event, handler.clone()).await;
2764                    } else if community::v2::realtime::subscription_id().await.as_ref() == Some(&subscription_id)
2765                        || community::v2::realtime::poolwide_subscription_id().await.as_ref() == Some(&subscription_id)
2766                    {
2767                        // Concord v2 plane events (authors-addressed kind-1059/21059).
2768                        let session = state::SessionGuard::capture();
2769                        community::v2::realtime::dispatch_event(&session, *event, handler.clone()).await;
2770                    }
2771                }
2772                Ok(false)
2773            }
2774        }).await.map_err(|e| VectorError::Nostr(e.to_string()))?;
2775
2776        Ok(())
2777    }
2778
2779    /// Disconnect and clean up.
2780    pub async fn logout(&self) {
2781        if let Some(client) = state::nostr_client() {
2782            let _ = client.disconnect().await;
2783        }
2784        db::close_database();
2785    }
2786
2787    /// Tear down the current session for an in-process account swap — the account-agnostic core of
2788    /// the app's `reset_session()`. Advances the session generation FIRST so any background task
2789    /// holding a `SessionGuard` short-circuits before it can touch the next account's storage; shuts
2790    /// the client down (which ends any `listen()` notification loop bound to it, so the old account's
2791    /// events can't land in the new account's DB); closes the DB pool; and clears the key vaults plus
2792    /// all in-memory per-account state. Follow with `login()` to bind the next account, then re-attach
2793    /// `listen()`. (The app's `reset_session()` additionally clears Tauri-only caches it owns.)
2794    pub async fn swap_session(&self) {
2795        // FIRST — invalidate every captured guard before any teardown begins.
2796        state::bump_session_generation();
2797
2798        // Shut the client down before anything else: this detaches relay subscriptions and ends the
2799        // prior `listen()` loop, so it stops firing the old account's events into the new session.
2800        if let Some(client) = state::take_nostr_client() {
2801            let _ = client.shutdown().await;
2802        }
2803        db::close_database();
2804
2805        // Key vaults + transient secrets.
2806        state::ENCRYPTION_KEY.clear(&[&state::MY_SECRET_KEY]);
2807        state::MY_SECRET_KEY.clear(&[&state::ENCRYPTION_KEY]);
2808        {
2809            use zeroize::Zeroize;
2810            if let Ok(mut g) = state::MNEMONIC_SEED.lock() {
2811                if let Some(s) = g.as_mut() { s.zeroize(); }
2812                *g = None;
2813            }
2814            if let Ok(mut g) = state::PENDING_NSEC.lock() {
2815                if let Some(s) = g.as_mut() { s.zeroize(); }
2816                *g = None;
2817            }
2818        }
2819
2820        // In-memory per-account state owned by vector-core's globals.
2821        {
2822            let mut st = state::STATE.lock().await;
2823            st.profiles.clear();
2824            st.chats.clear();
2825            st.db_loaded = false;
2826            st.is_syncing = false;
2827        }
2828        state::WRAPPER_ID_CACHE.lock().await.clear();
2829        state::PENDING_EVENTS.lock().await.clear();
2830        state::set_active_chat(None);
2831        crate::profile::sync::clear_profile_sync_queue();
2832        crate::inbox_relays::clear_inbox_relay_cache();
2833        crate::emoji_packs::clear_nip65_cache();
2834        // Chat/user row-id caches are PER-ACCOUNT (row ids belong to the prior account's DB). Not clearing
2835        // them here let a swapped-in account resolve a channel/npub to the WRONG (prior-account) row id →
2836        // saves FK-failed silently + reads hit the wrong row (e.g. a community member vanished post-swap).
2837        crate::db::clear_id_caches();
2838        // Community sync RAM cache (page cursors, history-start, in-flight, invite preload) is
2839        // account-scoped — drop it so the next account can't read A's cursors/warmed pages. The
2840        // generation stamp self-invalidates too, but clear explicitly for parity with the GUI swap.
2841        crate::community::cache::clear();
2842        // Community realtime route/subscription state is account-scoped (channel keys + banned sets);
2843        // drop it so a swapped-in account can't listen on the prior account's pseudonyms.
2844        crate::community::realtime::clear().await;
2845        crate::community::v2::realtime::clear().await;
2846        // Theme-pack emoji tags are account-scoped; leaving the prior account's set active would tag the
2847        // next account's outbound messages with A's theme shortcodes (leaking A's pack Blossom URLs). The
2848        // frontend re-registers the new account's theme, but only if it HAS one — clear to be safe.
2849        crate::emoji_packs::set_theme_emoji_tags(Vec::new());
2850    }
2851}
2852
2853#[cfg(test)]
2854mod facade_tests {
2855    use super::*;
2856
2857    /// SSRF regression: `download_attachment` must reject a private/link-local URL via
2858    /// `validate_url_not_private` BEFORE any network fetch (the URL is attacker-controlled).
2859    #[tokio::test]
2860    async fn download_attachment_rejects_private_url() {
2861        let att = crate::types::Attachment {
2862            url: "http://169.254.169.254/latest/meta-data/".to_string(),
2863            ..Default::default()
2864        };
2865        match VectorCore.download_attachment(&att).await {
2866            Err(VectorError::Other(msg)) => {
2867                assert!(msg.contains("Private/internal"), "expected SSRF rejection, got: {msg}")
2868            }
2869            other => panic!("expected SSRF rejection, got {other:?}"),
2870        }
2871    }
2872
2873    #[tokio::test]
2874    async fn download_attachment_rejects_empty_url() {
2875        let att = crate::types::Attachment::default();
2876        assert!(VectorCore.download_attachment(&att).await.is_err());
2877    }
2878
2879    /// The facade dual-stack dispatch: a v2 community surfaces in `list_communities`
2880    /// with `version: 2`, and `v2_community_for_channel` routes its channels to the
2881    /// v2 send path — while a v1 community is untouched (version 1).
2882    #[tokio::test]
2883    async fn list_communities_and_channel_routing_are_protocol_aware() {
2884        use crate::community::transport::memory::MemoryRelay;
2885        use nostr_sdk::prelude::Keys;
2886
2887        let _guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2888        crate::db::close_database();
2889        crate::db::clear_id_caches();
2890        let tmp = tempfile::tempdir().unwrap();
2891        // A valid bech32-charset, npub-length account dir name.
2892        let acct = {
2893            const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
2894            let mut s = String::from("npub1");
2895            for i in 0..58 {
2896                s.push(B[(i * 7 + 3) % 32] as char);
2897            }
2898            s
2899        };
2900        std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
2901        crate::db::set_app_data_dir(tmp.path().to_path_buf());
2902        crate::db::set_current_account(acct.clone()).unwrap();
2903        crate::db::init_database(&acct).unwrap();
2904        let _ = crate::state::take_nostr_client();
2905        let me = Keys::generate();
2906        crate::state::MY_SECRET_KEY.store_from_keys(&me, &[]);
2907        crate::state::set_my_public_key(me.public_key());
2908
2909        // Create a v2 community directly through the v2 service (offline).
2910        let relay = MemoryRelay::new();
2911        let community = crate::community::v2::service::create_community(&relay, "V2 Guild", vec!["wss://r".into()], None)
2912            .await
2913            .unwrap();
2914        let channel_hex = crate::simd::hex::bytes_to_hex_32(&community.channels[0].id.0);
2915
2916        // The facade lists it as version 2, owned by me.
2917        let listed = VectorCore.list_communities().await;
2918        let v2 = listed.iter().find(|c| c["version"] == 2).expect("the v2 community is listed");
2919        assert_eq!(v2["name"], "V2 Guild");
2920        assert_eq!(v2["is_owner"], true);
2921        assert_eq!(v2["channels"][0]["channel_id"], channel_hex);
2922
2923        // The channel routes to the v2 send path.
2924        assert_eq!(
2925            VectorCore.v2_community_for_channel(&channel_hex).unwrap(),
2926            Some(community.identity.community_id),
2927            "a v2 channel is routed to v2"
2928        );
2929        // An unknown channel routes nowhere (would fall through to v1).
2930        assert_eq!(VectorCore.v2_community_for_channel(&"00".repeat(32)).unwrap(), None);
2931    }
2932
2933    /// The facade builds a v2 invite URL by trimming `/invite` off the v1
2934    /// constant (v2's `build_invite_url` re-appends its own `/invite/<naddr>`).
2935    /// Lock that the derived URL is v2-shaped and round-trips through the v2
2936    /// parser — a stale constant or a double-`/invite` would silently break joins.
2937    #[test]
2938    fn v2_invite_url_base_derivation_round_trips() {
2939        use crate::community::v2::derive::TOKEN_LEN;
2940        use crate::community::v2::invite::{build_invite_url, parse_invite_link};
2941        use nostr_sdk::prelude::Keys;
2942        let base = crate::community::public_invite::INVITE_URL_BASE.trim_end_matches("/invite");
2943        assert!(!base.ends_with("/invite"), "the bare domain must not carry /invite");
2944        let signer = Keys::generate();
2945        let token = [0x07u8; TOKEN_LEN];
2946        let url = build_invite_url(base, &signer.public_key(), &token, &[]).unwrap();
2947        assert!(url.contains("/invite/"), "a v2 URL carries the naddr path");
2948        assert!(!url.contains("/invite/invite/"), "no doubled /invite from the base");
2949        let parsed = parse_invite_link(&url).unwrap();
2950        assert_eq!(parsed.link_signer, signer.public_key());
2951        assert_eq!(parsed.token, token);
2952    }
2953}