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 logging;
33pub mod error;
34pub mod traits;
35
36// Nostr SDK trait imports needed for bech32 operations
37use crate::event_ext::FinalizeUnsignedWithId;
38use nostr_sdk::prelude::{FinalizeEventAsync, ToBech32};
39
40// === Core Types ===
41pub mod event_ext;
42pub mod tags;
43pub mod types;
44pub mod profile;
45pub mod chat;
46pub mod compact;
47
48// === State ===
49pub mod state;
50
51// === Debug Stats ===
52#[cfg(debug_assertions)]
53pub mod stats;
54
55// === Crypto ===
56pub mod crypto;
57
58// === Signer (polymorphic: local vault vs. NIP-46 remote bunker) ===
59pub mod signer;
60
61// === NIP-55 offline signer (on-device Amber over Android IPC) ===
62pub mod nip55;
63
64// === Database ===
65pub mod db;
66
67// === Network ===
68pub mod net;
69pub mod negentropy;
70pub mod blossom;
71pub mod blossom_servers;
72pub mod blossom_capabilities;
73pub mod inbox_relays;
74pub mod emoji_packs;
75pub mod emoji_usage;
76pub mod badges;
77pub mod bot_interface;
78pub mod webxdc;
79#[cfg(feature = "tor")]
80pub mod tor;
81
82/// NIP-42 authenticator.
83///
84/// Many Concord/Armada communities live on AUTH-gating relays (Ditto's default
85/// gates kind-1059), where an unauthenticated client silently reads back ZERO
86/// events — a join's control-plane verify then fails closed and every community
87/// fetch comes up empty. Registering this is what unlocks those reads; a relay
88/// that doesn't challenge is unaffected.
89///
90/// The signer is resolved per challenge rather than captured at client
91/// construction, so a bunker that connects later (or an account swap) authks
92/// under the identity that is live *now*.
93#[derive(Debug)]
94pub struct VectorAuthenticator;
95
96impl nostr_sdk::prelude::Authenticator for VectorAuthenticator {
97    fn make_auth_event<'a>(
98        &'a self,
99        relay_url: &'a nostr_sdk::prelude::RelayUrl,
100        challenge: &'a str,
101    ) -> nostr_sdk::prelude::BoxedFuture<'a, std::result::Result<nostr_sdk::prelude::Event, nostr_sdk::prelude::Error>>
102    {
103        Box::pin(async move {
104            let signer =
105                signer::active_signer().map_err(nostr_sdk::prelude::Error::other)?;
106            Ok(nostr_sdk::prelude::EventBuilder::auth(challenge, relay_url.clone())
107                .finalize_async(&signer)
108                .await?)
109        })
110    }
111}
112
113/// A `ClientBuilder` carrying Vector's client-wide policy: NIP-42 auth plus the
114/// embedded-Tor SOCKS proxy.
115///
116/// Callers should start from this rather than `ClientBuilder::new()` so both come
117/// along automatically.
118///
119/// The proxy is a closure, not a fixed address: nostr resolves it per connection
120/// attempt, so it reads the *current* Tor state. That covers relays added later
121/// in the session, which previously needed the transport re-applied per
122/// `add_relay`.
123pub fn nostr_client_builder() -> nostr_sdk::prelude::ClientBuilder {
124    apply_tor_proxy(
125        nostr_sdk::prelude::ClientBuilder::new()
126            .authenticator(VectorAuthenticator)
127            // The pool's own attempts need the Tor floor too, not just our explicit
128            // `try_connect` calls (0.45 default is 15s — under a circuit build).
129            .connect_timeout(relay_connect_timeout(std::time::Duration::from_secs(15))),
130    )
131}
132
133/// Register a relay with the pool's own auto-reconnect disabled.
134///
135/// Vector drives every reconnect from its reconcile loop, because it needs the
136/// connection lifecycle to be observable and to sequence with health checks and
137/// the Tor transport switch. The pool's retry is invisible to all of that, so two
138/// schedules end up fighting over one socket.
139///
140/// This exists as a helper rather than a per-call `.reconnect(false)` because
141/// `reconnect` is the one relay option `ClientBuilder` cannot default: it lives
142/// only on `RelayOptions`, so every registration site has to opt out by hand, and
143/// most of them silently didn't.
144pub trait ClientRelayExt {
145    /// `Client::add_relay` with `reconnect(false)` already applied.
146    fn add_managed_relay<'client, 'url, U>(
147        &'client self,
148        url: U,
149    ) -> nostr_sdk::prelude::AddRelay<'client, 'url>
150    where
151        U: Into<nostr_sdk::prelude::RelayUrlArg<'url>>;
152}
153
154impl ClientRelayExt for nostr_sdk::prelude::Client {
155    fn add_managed_relay<'client, 'url, U>(
156        &'client self,
157        url: U,
158    ) -> nostr_sdk::prelude::AddRelay<'client, 'url>
159    where
160        U: Into<nostr_sdk::prelude::RelayUrlArg<'url>>,
161    {
162        self.add_relay(url).reconnect(false)
163    }
164}
165
166/// Re-send every live subscription this relay is supposed to carry, after VECTOR
167/// reconnected it.
168///
169/// Vector owns every reconnect (`add_managed_relay` ⇒ `reconnect(false)`), and the
170/// pool re-applies live subs only inside its own retry path — which is exactly the
171/// path that was turned off. So a dropped socket comes back carrying NOTHING, and a
172/// catch-up fetch is not a subscription: the data lands once and then the stream is
173/// silent forever. Only an AUTH-gating relay healed, via its post-challenge re-send.
174///
175/// Driven off the pool's own subscription table rather than a list of known
176/// subscriptions, so a future subscription is covered without touching this. Only
177/// ids the pool already associates with `relay` are re-sent, so a relay-targeted
178/// subscription is never widened onto a relay it deliberately excluded. Same-id
179/// REQs are idempotent.
180pub async fn resubscribe_relay_after_reconnect(
181    client: &nostr_sdk::prelude::Client,
182    relay: &nostr_sdk::prelude::RelayUrl,
183) {
184    for (id, per_relay) in client.subscriptions().await {
185        let Some(filters) = per_relay.get(relay) else { continue };
186        if filters.is_empty() {
187            continue;
188        }
189        let _ = client
190            .subscribe(nostr_sdk::prelude::ReqTarget::single(relay.clone(), filters.clone()))
191            .with_id(id)
192            .await;
193    }
194}
195
196/// Minimum a relay connect attempt gets while Tor is on.
197///
198/// Circuit construction dominates the handshake and routinely runs tens of
199/// seconds, especially on the first connection after the toggle.
200#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
201const TOR_RELAY_CONNECT_FLOOR: std::time::Duration = std::time::Duration::from_secs(60);
202
203/// Adjust a relay connect budget for the transport actually in use.
204///
205/// Clearnet TCP+TLS settles in well under a second, so the tight per-call budgets
206/// are right there and each caller's intent is preserved. Under Tor those same
207/// budgets expire mid-circuit, and because the health-check and reconcile loops
208/// treat a timeout as "unhealthy" they call `disconnect()` — which terminates the
209/// connection task — then retry, so a relay churns `pending → terminated` forever
210/// and never connects. Raising the floor lets the circuit finish.
211pub fn relay_connect_timeout(clearnet: std::time::Duration) -> std::time::Duration {
212    #[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
213    {
214        if !matches!(tor::transport_state(), tor::TorTransportState::Disabled) {
215            return clearnet.max(TOR_RELAY_CONNECT_FLOOR);
216        }
217    }
218    clearnet
219}
220
221/// Floor for a relay round-trip (request → response) while Tor is active.
222#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
223const TOR_RELAY_REQUEST_FLOOR: std::time::Duration = std::time::Duration::from_secs(30);
224
225/// Adjust a relay request budget for the transport actually in use.
226///
227/// Companion to [`relay_connect_timeout`] for round trips rather than connections.
228/// A relay that answers a probe in 200ms direct can take many seconds through three
229/// hops, so a clearnet-sized budget reads a healthy relay as dead.
230pub fn relay_request_timeout(clearnet: std::time::Duration) -> std::time::Duration {
231    #[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
232    {
233        if !matches!(tor::transport_state(), tor::TorTransportState::Disabled) {
234            return clearnet.max(TOR_RELAY_REQUEST_FLOOR);
235        }
236    }
237    clearnet
238}
239
240/// Apply the Tor proxy policy to any `ClientBuilder`.
241///
242/// Separate from [`nostr_client_builder`] because a client that authenticates as
243/// something other than the user (the Concord stream-auth plane key) still needs
244/// the same transport: without it the plane fetch connects direct and ties the
245/// user's IP to community membership.
246pub fn apply_tor_proxy(
247    builder: nostr_sdk::prelude::ClientBuilder,
248) -> nostr_sdk::prelude::ClientBuilder {
249    #[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
250    let builder = builder.proxy(nostr_sdk::prelude::Proxy::custom(|_url| tor_proxy_target()));
251    builder
252}
253
254/// Resolve the proxy every connection attempt must use, for the transport in use.
255///
256/// Named rather than inlined into the `Proxy::custom` closure so the failsafe is
257/// testable: returning `None` here means "connect direct", so the only leak-safe
258/// answer while Tor is the chosen transport but not yet up is the blackhole.
259#[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
260fn tor_proxy_target() -> Option<std::net::SocketAddr> {
261    match tor::transport_state() {
262        tor::TorTransportState::Active(addr) => Some(addr),
263        // Tor failsafe: route to a blackhole so a relay socket can't come up
264        // direct while Tor is mid-bootstrap.
265        tor::TorTransportState::RequiredButInactive => Some(tor::blackhole_proxy_addr()),
266        tor::TorTransportState::Disabled => None,
267    }
268}
269
270/// Sign an `EventBuilder` with the session signer.
271///
272/// Stands in for 0.44's `Client::sign_event_builder`, which went away when the
273/// client stopped owning a signer.
274pub async fn sign_builder(
275    builder: nostr_sdk::prelude::EventBuilder,
276) -> std::result::Result<nostr_sdk::prelude::Event, String> {
277    let signer = signer::active_signer()?;
278    builder
279        .finalize_async(&signer)
280        .await
281        .map_err(|e| e.to_string())
282}
283
284/// Sign an `EventBuilder` with the session signer and publish it.
285///
286/// Stands in for 0.44's `Client::send_event_builder`.
287pub async fn sign_and_send(
288    client: &nostr_sdk::prelude::Client,
289    builder: nostr_sdk::prelude::EventBuilder,
290) -> std::result::Result<nostr_sdk::prelude::SendEventOutput, String> {
291    let event = sign_builder(builder).await?;
292    client
293        .send_event(&event)
294        .await
295        .map_err(|e| e.to_string())
296}
297
298/// Seal, wrap and publish a rumor to `receiver`.
299///
300/// Stands in for 0.44's `Client::gift_wrap` / `gift_wrap_to`, which went away
301/// with the client's signer. An empty `relays` publishes pool-wide, matching
302/// `gift_wrap`; a non-empty one targets those relays, matching `gift_wrap_to`.
303pub async fn send_gift_wrap<'u, I, U, T>(
304    client: &nostr_sdk::prelude::Client,
305    relays: I,
306    receiver: &nostr_sdk::prelude::PublicKey,
307    rumor: nostr_sdk::prelude::UnsignedEvent,
308    extra_tags: T,
309) -> std::result::Result<nostr_sdk::prelude::SendEventOutput, String>
310where
311    I: IntoIterator<Item = U>,
312    U: Into<nostr_sdk::prelude::RelayUrlArg<'u>>,
313    T: IntoIterator<Item = nostr_sdk::prelude::Tag>,
314{
315    let signer = signer::active_signer()?;
316    let wrap = nostr_sdk::prelude::GiftWrapBuilder::new(*receiver, rumor)
317        .extra_tags(extra_tags)
318        .finalize_async(&signer)
319        .await
320        .map_err(|e| e.to_string())?;
321    let targets: Vec<nostr_sdk::prelude::RelayUrlArg<'u>> =
322        relays.into_iter().map(Into::into).collect();
323    if targets.is_empty() {
324        client.send_event(&wrap).await.map_err(|e| e.to_string())
325    } else {
326        client
327            .send_event(&wrap)
328            .to(targets)
329            .await
330            .map_err(|e| e.to_string())
331    }
332}
333
334/// Capabilities for a Community / "external" relay: GOSSIP only.
335///
336/// GOSSIP is read/write-capable when TARGETED — `can_read()` is
337/// `READ|GOSSIP|DISCOVERY` and `can_write()` is `WRITE|GOSSIP`, so per-relay
338/// targeted ops pass. But pool-wide ops select READ-only / WRITE-only relays, so
339/// the DM/giftwrap subscription and the user's outbox skip GOSSIP relays — the
340/// user's own traffic never touches relays they don't own.
341///
342/// No PING counterpart any more: 0.45 demoted PING from a capability flag to a
343/// per-relay option (`AddRelay::ping`) that already defaults to true, with
344/// `sleep_when_idle` defaulting to false. The 24/7 keepalive this used to buy is
345/// now the default, so it doesn't belong in the capability set.
346pub fn community_relay_capabilities() -> nostr_sdk::prelude::RelayCapabilities {
347    nostr_sdk::prelude::RelayCapabilities::GOSSIP
348}
349
350/// Relay options for a Discovery Relay (see `state::DISCOVERY_RELAYS`): the same
351/// GOSSIP|PING targeted-only isolation as Community relays — reachable via
352/// `fetch_events_from` / `send_event_to`, invisible to pool-wide DM/profile ops.
353/// An overlap with a user relay keeps the user's READ+WRITE flags (`add_relay`
354/// no-ops on an already-pooled url).
355pub fn discovery_relay_capabilities() -> nostr_sdk::prelude::RelayCapabilities {
356    community_relay_capabilities()
357}
358
359// === Event Storage ===
360pub mod stored_event;
361
362// === Rumor Processing ===
363pub mod rumor;
364
365// === Messaging ===
366pub mod sending;
367
368// === Per-DM Wallpapers ===
369pub mod wallpaper;
370
371// === Message Deletion (NIP-09 against retained gift-wraps) ===
372pub mod deletion;
373pub mod self_destruct;
374
375// === SIMD Operations ===
376pub mod simd;
377
378// === Community protocol (GROUP_PROTOCOL.md) ===
379pub mod community;
380
381// === Event Handler ===
382pub mod event_handler;
383
384// === Re-exports for convenience ===
385pub use types::{Message, Attachment, Reaction, EditEntry, ImageMetadata, SiteMetadata, LoginResult, AttachmentFile, mention, extract_mentions};
386pub use profile::{Profile, ProfileFlags, SlimProfile, Status};
387pub use chat::{Chat, ChatType, ChatMetadata, SerializableChat};
388pub use compact::{CompactMessage, CompactMessageVec, NpubInterner};
389pub use state::{
390    ChatState, NOSTR_CLIENT, MY_SECRET_KEY, MY_PUBLIC_KEY, STATE, ENCRYPTION_KEY,
391    nostr_client, my_public_key, has_active_session,
392    set_nostr_client, set_my_public_key,
393    take_nostr_client, clear_my_public_key,
394    set_pending_bunker_setup, pending_bunker_setup, clear_pending_bunker_setup,
395    set_pending_nip55_setup, pending_nip55_setup, clear_pending_nip55_setup,
396};
397pub use crypto::{GuardedKey, GuardedSigner};
398pub use signer::{
399    SignerKind, signer_kind, set_signer_kind, is_bunker, is_keyless,
400    BUNKER_SIGNER, bunker_signer, set_bunker_signer, take_bunker_signer,
401    build_bunker_signer, prewarm_bunker, drain_bunker_state,
402    parse_bunker_remote_pubkey, parse_bunker_relays,
403    BunkerConnectionState, bunker_state, set_bunker_state,
404    VectorAuthUrlHandler, attempt_bunker_login, WatchedBunkerSigner,
405    vector_metadata, build_nostrconnect_uri, build_nostrconnect_session,
406    VECTOR_APP_NAME, VECTOR_APP_URL, VECTOR_APP_ICON,
407};
408pub use nip55::{
409    Nip55Backend, Nip55Error, Nip55ResolverOutcome, Nip55Signer, Nip55State,
410    set_nip55_backend, nip55_backend, nip55_state, set_nip55_state, drain_nip55_state,
411    nip55_is_installed, nip55_pair, nip55_perms_json,
412    VECTOR_NIP55_SIGN_KINDS, VECTOR_NIP55_ENCRYPT_TYPES,
413};
414pub use error::{VectorError, Result};
415pub use traits::{EventEmitter, NoOpEmitter, set_event_emitter, emit_event};
416pub use db::{set_app_data_dir, get_app_data_dir};
417pub use sending::{SendCallback, NoOpSendCallback, SendConfig, SendResult};
418pub use deletion::{delete_own_dm, DeleteOutcome};
419pub use stored_event::{StoredEvent, StoredEventBuilder, SystemEventType};
420pub use rumor::{RumorEvent, RumorContext, ConversationType, RumorProcessingResult, process_rumor};
421pub use profile::{SyncPriority, ProfileSyncHandler, NoOpProfileSyncHandler};
422pub use event_handler::{InboundEventHandler, NoOpEventHandler, PreparedEvent, process_event};
423
424use std::path::PathBuf;
425use std::sync::Arc;
426
427// ============================================================================
428// VectorCore — High-level API
429// ============================================================================
430
431/// Configuration for initializing VectorCore.
432pub struct CoreConfig {
433    /// Path to the app data directory (e.g., ~/.local/share/io.vectorapp/data/)
434    pub data_dir: PathBuf,
435    /// Optional event emitter for UI integration
436    pub event_emitter: Option<Box<dyn EventEmitter>>,
437}
438
439/// The main entry point for Vector Core.
440///
441/// Provides a high-level API for all Vector operations. Internally uses
442/// global state (same pattern as the Tauri backend) for compatibility.
443///
444/// ```no_run
445/// use vector_core::{VectorCore, CoreConfig};
446/// use std::path::PathBuf;
447///
448/// # async fn example() -> vector_core::Result<()> {
449/// let core = VectorCore::init(CoreConfig {
450///     data_dir: PathBuf::from("/tmp/vector-data"),
451///     event_emitter: None,
452/// })?;
453///
454/// // Login with nsec
455/// let result = core.login("nsec1...", None).await?;
456/// println!("Logged in as {}", result.npub);
457/// # Ok(())
458/// # }
459/// ```
460#[derive(Clone, Copy)]
461pub struct VectorCore;
462
463impl VectorCore {
464    /// Initialize Vector Core with the given configuration.
465    pub fn init(config: CoreConfig) -> Result<Self> {
466        // Set data directory
467        db::set_app_data_dir(config.data_dir);
468
469        // Set event emitter (or no-op)
470        if let Some(emitter) = config.event_emitter {
471            traits::set_event_emitter(emitter);
472        }
473
474        // Install rustls ring provider
475        let _ = rustls::crypto::ring::default_provider().install_default();
476
477        Ok(VectorCore)
478    }
479
480    /// Get all available accounts.
481    pub fn accounts(&self) -> Result<Vec<String>> {
482        db::get_accounts().map_err(VectorError::from)
483    }
484
485    /// Login with an nsec key or mnemonic seed phrase.
486    pub async fn login(&self, key: &str, password: Option<&str>) -> Result<LoginResult> {
487        use nostr_sdk::prelude::*;
488
489        // Parse the key
490        let keys = if key.starts_with("nsec1") {
491            let secret = SecretKey::from_bech32(key)
492                .map_err(|e| VectorError::Nostr(format!("Invalid nsec: {}", e)))?;
493            Keys::new(secret)
494        } else {
495            // Treat as mnemonic (NIP-06: derive from BIP-39 seed)
496            Keys::from_mnemonic(key, None)
497                .map_err(|e| VectorError::Nostr(format!("Key derivation failed: {}", e)))?
498        };
499
500        let public_key = keys.public_key();
501        let npub = public_key.to_bech32()
502            .map_err(|e| VectorError::Nostr(format!("Failed to encode npub: {}", e)))?;
503
504        // Store in GuardedKey vault (pass other vaults to protect during decoy writes)
505        let secret_bytes = keys.secret_key().to_secret_bytes();
506        state::MY_SECRET_KEY.set(secret_bytes, &[&state::ENCRYPTION_KEY]);
507        state::set_my_public_key(public_key);
508
509        // Initialize database for this account
510        db::set_current_account(npub.clone())?;
511        db::init_database(&npub)?;
512
513        // Store nsec for encryption setup
514        {
515            let nsec = keys.secret_key().to_bech32()
516                .map_err(|e| VectorError::Nostr(format!("Failed to encode nsec: {}", e)))?;
517            *state::PENDING_NSEC.lock().unwrap() = Some(nsec.clone());
518
519            // NEVER clobber an existing encrypted key with the plaintext nsec. An account with encryption
520            // enabled keeps its key encrypted-at-rest (PIN-derived); overwriting it with the raw nsec — e.g.
521            // a no-password headless/diagnostic login (the concord CLI) — would leave the GUI deriving the
522            // right key from the correct PIN but trying to decrypt a value that's no longer ciphertext, i.e.
523            // "incorrect pin" with the real key effectively lost. MY_SECRET_KEY is already set in-memory above,
524            // so login works regardless; only persist the raw key when there's no encrypted key to protect.
525            let existing_encrypted = db::get_pkey().ok().flatten().is_some_and(|v| !v.starts_with("nsec1"));
526            if !(state::resolve_encryption_enabled_from_db() && existing_encrypted) {
527                db::set_pkey(&nsec)?;
528            }
529        }
530
531        // Use the canonical resolver so this high-level API agrees with
532        // crypto::is_encryption_enabled and the Android bg-sync probe.
533        let has_encryption = state::resolve_encryption_enabled_from_db();
534
535        if has_encryption {
536            if let Some(pwd) = password {
537                let key = crate::crypto::hash_pass(pwd).await;
538                state::ENCRYPTION_KEY.set(key, &[&state::MY_SECRET_KEY]);
539            }
540        }
541        // Seed the atomic unconditionally — `is_encryption_enabled_fast()`
542        // must agree with the DB regardless of branch.
543        state::init_encryption_enabled();
544
545        // Build Nostr client — tor-aware options so a headless consumer with
546        // the Tor pref ON proxies (or blackholes) instead of dialing direct.
547        let client = crate::nostr_client_builder()
548            // Relay health monitor — powers the reconnect-driven catch-up in `listen()`.
549            .monitor(Monitor::new(1024))
550            .build();
551
552        // Add trusted relays
553        for relay in state::TRUSTED_RELAYS {
554            client.add_managed_relay(*relay).await.ok();
555        }
556
557        // Connect
558        client.connect().await;
559
560        let _ = { state::set_nostr_client(client); Ok::<(), ()>(()) };
561
562        Ok(LoginResult { npub, has_encryption })
563    }
564
565    /// Generate a fresh random account secret key (bech32 nsec). Lets a headless client spin up a
566    /// brand-new identity (`add_account` with no key) without depending on nostr-sdk directly.
567    pub fn generate_nsec(&self) -> Result<String> {
568        use nostr_sdk::prelude::*;
569        Keys::generate().secret_key().to_bech32()
570            .map_err(|e| VectorError::Nostr(format!("Failed to encode nsec: {}", e)))
571    }
572
573    /// Send a NIP-17 gift-wrapped text DM using the full pipeline. Retries a
574    /// transient publish miss (headless preset, 3 attempts) so an SDK/CLI bot rides
575    /// out a relay blip instead of silently dropping the message on the first miss;
576    /// `self_send: false` keeps it a plain send (no inbox self-copy).
577    pub async fn send_dm(&self, to_npub: &str, content: &str) -> Result<sending::SendResult> {
578        let config = SendConfig { self_send: false, ..SendConfig::headless() };
579        sending::send_dm(to_npub, content, None, &config, Arc::new(NoOpSendCallback)).await
580            .map_err(|e| VectorError::Other(e))
581    }
582
583    /// Send a DM as a threaded reply to `replied_to` (an existing message's event id).
584    pub async fn send_dm_reply(&self, to_npub: &str, replied_to: &str, content: &str) -> Result<sending::SendResult> {
585        let config = SendConfig { self_send: false, ..SendConfig::headless() };
586        sending::send_dm(to_npub, content, Some(replied_to), &config, Arc::new(NoOpSendCallback)).await
587            .map_err(|e| VectorError::Other(e))
588    }
589
590    /// Download a received attachment and decrypt it to plaintext bytes. Fetches the encrypted blob
591    /// from its Blossom URL (SSRF/Tor-aware client, size-capped) and AES-decrypts with the
592    /// attachment's embedded key + nonce. Walks the primary URL then any BUD-04 `fallback`
593    /// mirrors (same ciphertext on other hosts) until one serves. Prefer
594    /// [`download_attachment_from`](Self::download_attachment_from) when the message author is
595    /// known — it adds the BUD-03 hash-swap over the author's advertised servers.
596    pub async fn download_attachment(&self, attachment: &Attachment) -> Result<Vec<u8>> {
597        self.download_attachment_from(attachment, None).await
598    }
599
600    /// [`download_attachment`](Self::download_attachment) with the full source walk: primary URL →
601    /// embedded `fallback` mirrors → BUD-03 hash-swap (the same content-address on each of the
602    /// author's kind-10063 servers). `author_npub` is the message author (your own npub for your
603    /// own messages); `None` skips the hash-swap stage.
604    pub async fn download_attachment_from(
605        &self,
606        attachment: &Attachment,
607        author_npub: Option<&str>,
608    ) -> Result<Vec<u8>> {
609        use futures_util::StreamExt;
610        const MAX_DOWNLOAD: usize = 256 * 1024 * 1024;
611        if attachment.url.is_empty() {
612            return Err(VectorError::Other("attachment has no URL".into()));
613        }
614        let client = crate::net::build_http_client(std::time::Duration::from_secs(120)).map_err(VectorError::Other)?;
615        let mut last_err = String::from("download failed");
616        let mut candidates: Vec<String> = vec![attachment.url.clone()];
617        candidates.extend(attachment.fallback_urls.iter().cloned());
618        let mut hash_swap_tried = false;
619        let mut i = 0;
620        'sources: while i < candidates.len() {
621            let url = candidates[i].clone();
622            i += 1;
623            // One-time last resort once every embedded source has failed: the author's advertised
624            // servers may hold the blob under the same content-address.
625            let extend_with_swap = |candidates: &mut Vec<String>, servers: &[String]| {
626                let extra = crate::blossom::hash_swap_candidates(&attachment.url, servers);
627                for c in extra {
628                    if !candidates.contains(&c) {
629                        candidates.push(c);
630                    }
631                }
632            };
633            macro_rules! next_source {
634                () => {{
635                    log_net_fail!("[Download] source failed ({}): {}", url, last_err);
636                    if i == candidates.len() && !hash_swap_tried {
637                        hash_swap_tried = true;
638                        let servers = crate::blossom_servers::author_swap_servers(author_npub, false).await;
639                        extend_with_swap(&mut candidates, &servers);
640                    }
641                    continue 'sources;
642                }};
643            }
644            // SSRF guard: URLs are attacker-controlled (off an inbound message). build_http_client
645            // only validates redirect HOPS, not the initial request — so validate each source here
646            // (matches the native download path). With Tor off this is the only egress guard.
647            if let Err(e) = crate::net::validate_url_not_private(&url) {
648                last_err = e.to_string();
649                next_source!();
650            }
651            let resp = match client.get(&url).send().await {
652                Ok(r) => r,
653                Err(e) => {
654                    last_err = format!("download: {e}");
655                    next_source!();
656                }
657            };
658            if !resp.status().is_success() {
659                last_err = format!("download failed: HTTP {}", resp.status());
660                next_source!();
661            }
662            // Stream with a cap so a hostile/oversized blob can't OOM the process. The cap is
663            // permanent — every mirror serves the same blob, so don't bother trying the next.
664            let mut encrypted: Vec<u8> = Vec::with_capacity(
665                resp.content_length().map(|l| (l as usize).min(MAX_DOWNLOAD)).unwrap_or(64 * 1024),
666            );
667            let mut stream = resp.bytes_stream();
668            while let Some(chunk) = stream.next().await {
669                let chunk = match chunk {
670                    Ok(c) => c,
671                    Err(e) => {
672                        last_err = format!("read body: {e}");
673                        next_source!();
674                    }
675                };
676                if encrypted.len() + chunk.len() > MAX_DOWNLOAD {
677                    return Err(VectorError::Other("attachment exceeds 256 MiB cap".into()));
678                }
679                encrypted.extend_from_slice(&chunk);
680            }
681            match crate::crypto::decrypt_data(&encrypted, &attachment.key, &attachment.nonce) {
682                Ok(plain) => {
683                    if i > 1 {
684                        log_net_info!("[Download] fallback source {}/{} served {}", i, candidates.len(), url);
685                    }
686                    return Ok(plain);
687                }
688                Err(e) => {
689                    // A host serving wrong bytes under the right URL must not
690                    // veto sources still holding the real ciphertext.
691                    last_err = format!("decrypt: {e}");
692                    next_source!();
693                }
694            }
695        }
696        log_net_fail!("[Download] all {} source(s) failed for {}: {}", candidates.len(), attachment.url, last_err);
697        Err(VectorError::Other(last_err))
698    }
699
700    /// Send a NIP-17 gift-wrapped file attachment DM.
701    pub async fn send_file(&self, to_npub: &str, file_path: &str) -> Result<sending::SendResult> {
702        let path = std::path::Path::new(file_path);
703        let bytes = std::fs::read(path)
704            .map_err(|e| VectorError::Io(e))?;
705        let filename = path.file_name()
706            .and_then(|n| n.to_str())
707            .unwrap_or("file");
708        let extension = path.extension()
709            .and_then(|e| e.to_str())
710            .unwrap_or("bin");
711
712        sending::send_file_dm(
713            to_npub,
714            std::sync::Arc::new(bytes),
715            filename,
716            extension,
717            None,
718            &SendConfig::default(),
719            Arc::new(NoOpSendCallback),
720        ).await.map_err(|e| VectorError::Other(e))
721    }
722
723    /// Send a NIP-25 reaction to a DM message. `emoji_url` carries the NIP-30
724    /// image URL when reacting with a custom-pack emoji (content stays
725    /// `:shortcode:`). Returns the reaction's rumor id. Local echo + persistence
726    /// are best-effort — the gift-wrap send is the source of truth.
727    pub async fn send_reaction(
728        &self,
729        to_npub: &str,
730        reference_id: &str,
731        emoji: &str,
732        emoji_url: Option<&str>,
733    ) -> Result<String> {
734        use nostr_sdk::prelude::*;
735
736        let client = state::nostr_client().ok_or(VectorError::Other("Not connected".into()))?;
737        let my_public_key = state::my_public_key().ok_or(VectorError::Other("Not logged in".into()))?;
738
739        let reference_event = EventId::from_hex(reference_id)
740            .map_err(|e| VectorError::Nostr(e.to_string()))?;
741        let receiver_pubkey = PublicKey::from_bech32(to_npub)
742            .map_err(|e| VectorError::Nostr(e.to_string()))?;
743
744        // NIP-30 custom-emoji tag — only when content is `:shortcode:` and a URL is present.
745        let custom_emoji_tag = emoji_url.and_then(|url| {
746            if !emoji.starts_with(':') || !emoji.ends_with(':') || emoji.len() < 3 || url.is_empty() {
747                return None;
748            }
749            let shortcode = &emoji[1..emoji.len() - 1];
750            if shortcode.is_empty() { return None; }
751            Some(Tag::custom("emoji", [shortcode.to_string(), url.to_string()]))
752        });
753
754        let reaction_target = nostr_sdk::prelude::nip25::ReactionTarget {
755            event_id: reference_event,
756            public_key: receiver_pubkey,
757            coordinate: None,
758            kind: Some(Kind::PrivateDirectMessage),
759            relay_hint: None,
760        };
761        let mut builder = EventBuilder::reaction(reaction_target, emoji);
762        if let Some(tag) = custom_emoji_tag {
763            builder = builder.tag(tag);
764        }
765        let rumor = builder.finalize_unsigned_with_id(my_public_key);
766        let inner_rumor_id = rumor.id;
767        let rumor_id = inner_rumor_id.ok_or(VectorError::Other("Failed to get rumor ID".into()))?.to_hex();
768
769        // Retain the recipient wrap's ephemeral key + targeted relays so the
770        // reaction can later be revoked with a NIP-09 relay nuke (mirrors the
771        // DM message send path). Without retention the reaction is undeletable.
772        let outcome = inbox_relays::send_gift_wrap_retained(&client, &receiver_pubkey, rumor.clone(), [])
773            .await.map_err(VectorError::Other)?;
774        if !outcome.output.success.is_empty() {
775            if let Some(rid) = inner_rumor_id {
776                if let Err(e) = db::nip17_keys::store_wrap_key(
777                    &outcome.wrap_event_id, &rid, &receiver_pubkey,
778                    db::nip17_keys::WrapRole::Recipient,
779                    &outcome.wrap_secret, &outcome.targeted_relays,
780                ) {
781                    crate::log_warn!("[Reaction] failed to persist wrap key: {}", e);
782                }
783            }
784        }
785
786        // Self-wrap for multi-device recovery + retain its key too, so another
787        // device (or this one) can later revoke. Bail on account swap.
788        let self_wrap_client = client.clone();
789        let self_wrap_session = state::SessionGuard::capture();
790        tokio::spawn(async move {
791            if !self_wrap_session.is_valid() { return; }
792            if let Ok(self_outcome) = inbox_relays::send_gift_wrap_retained(
793                &self_wrap_client, &my_public_key, rumor, [],
794            ).await {
795                if !self_wrap_session.is_valid() { return; }
796                if !self_outcome.output.success.is_empty() {
797                    if let Some(rid) = inner_rumor_id {
798                        let _ = db::nip17_keys::store_wrap_key(
799                            &self_outcome.wrap_event_id, &rid, &my_public_key,
800                            db::nip17_keys::WrapRole::SelfSend,
801                            &self_outcome.wrap_secret, &self_outcome.targeted_relays,
802                        );
803                    }
804                }
805            }
806        });
807
808        // Best-effort optimistic local echo + persistence.
809        let reaction = Reaction {
810            id: rumor_id.clone(),
811            reference_id: reference_id.to_string(),
812            author_id: my_public_key.to_bech32().unwrap_or_else(|_| my_public_key.to_hex()),
813            emoji: emoji.to_string(),
814            emoji_url: emoji_url.map(|s| s.to_string()),
815        };
816        let msg_for_save = {
817            let mut st = state::STATE.lock().await;
818            match st.add_reaction_to_message(reference_id, reaction) {
819                Some((cid, true)) => st.find_message(reference_id).map(|(_, m)| (cid, m)),
820                _ => None,
821            }
822        };
823        if let Some((cid, mut msg)) = msg_for_save {
824            let _ = db::events::save_message(&cid, &msg).await;
825            traits::emit_message_update(&cid, reference_id, &mut msg).await;
826        }
827
828        Ok(rumor_id)
829    }
830
831    /// Send an ephemeral typing indicator to a DM recipient. Fire-and-forget
832    /// with a 30-second NIP-40 expiry so relays purge it quickly.
833    pub async fn send_typing(&self, to_npub: &str) -> Result<()> {
834        use nostr_sdk::prelude::*;
835
836        let client = state::nostr_client().ok_or(VectorError::Other("Not connected".into()))?;
837        let my_public_key = state::my_public_key().ok_or(VectorError::Other("Not logged in".into()))?;
838        let pubkey = PublicKey::from_bech32(to_npub).map_err(|e| VectorError::Nostr(e.to_string()))?;
839
840        let expiry = Timestamp::from_secs(Timestamp::now().as_secs() + 30);
841        let rumor = EventBuilder::new(Kind::ApplicationSpecificData, "typing")
842            .tag(Tag::public_key(pubkey))
843            .tag(Tag::custom("d", vec!["vector"]))
844            .tag(Tag::expiration(expiry))
845            .finalize_unsigned_with_id(my_public_key);
846
847        // Client no longer wraps: build the wrap, then publish it to the target relays.
848        let signer = signer::active_signer().map_err(VectorError::Other)?;
849        let wrap = nostr_sdk::prelude::GiftWrapBuilder::new(pubkey, rumor.clone())
850            .extra_tags([Tag::expiration(expiry)])
851            .finalize_async(&signer)
852            .await
853            .map_err(|e| VectorError::Nostr(e.to_string()))?;
854        client
855            .send_event(&wrap)
856            .to(state::active_trusted_relays().await)
857            .await
858            .map_err(|e| VectorError::Nostr(e.to_string()))?;
859        Ok(())
860    }
861
862    /// Edit a DM you previously sent (kind-16 edit) with an optimistic local
863    /// echo. Returns the edit event id. Persistence is best-effort and only
864    /// happens when the chat already exists locally.
865    pub async fn edit_dm(&self, to_npub: &str, message_id: &str, new_content: &str) -> Result<String> {
866        use nostr_sdk::prelude::*;
867
868        let client = state::nostr_client().ok_or(VectorError::Other("Not connected".into()))?;
869        let my_public_key = state::my_public_key().ok_or(VectorError::Other("Not logged in".into()))?;
870        let my_npub = my_public_key.to_bech32().map_err(|e| VectorError::Nostr(e.to_string()))?;
871        let receiver_pubkey = PublicKey::from_bech32(to_npub).map_err(|e| VectorError::Nostr(e.to_string()))?;
872        let reference_event = EventId::from_hex(message_id).map_err(|e| VectorError::Nostr(e.to_string()))?;
873
874        // NIP-30: resolve `:shortcode:` so the edit carries emoji image tags.
875        let emoji_tags = emoji_packs::resolve_outbound_emoji_tags(new_content);
876
877        let mut builder = EventBuilder::new(
878            Kind::from_u16(stored_event::event_kind::MESSAGE_EDIT),
879            new_content,
880        ).tag(Tag::event(reference_event));
881        for et in &emoji_tags {
882            builder = builder.tag(Tag::custom(
883                "emoji",
884                [et.shortcode.clone(), et.url.clone()],
885            ));
886        }
887        let rumor = builder.finalize_unsigned_with_id(my_public_key);
888        let edit_id = rumor.id.ok_or(VectorError::Other("Failed to get edit rumor ID".into()))?.to_hex();
889        let edit_ts_ms = rumor.created_at.as_secs() * 1000;
890
891        // Optimistic local echo + best-effort persistence.
892        let msg_for_emit = {
893            let mut st = state::STATE.lock().await;
894            st.update_message_in_chat(to_npub, message_id, |msg| {
895                msg.apply_edit(new_content.to_string(), edit_ts_ms, emoji_tags.clone());
896                msg.preview_metadata = None;
897            })
898        };
899        if let Some(mut msg) = msg_for_emit {
900            traits::emit_message_update(to_npub, message_id, &mut msg).await;
901            if let Ok(db_chat_id) = db::id_cache::get_chat_id_by_identifier(to_npub) {
902                let _ = db::events::save_edit_event(
903                    &edit_id, message_id, new_content, &emoji_tags, db_chat_id, None, &my_npub,
904                ).await;
905            }
906        }
907
908        inbox_relays::send_gift_wrap(&client, &receiver_pubkey, rumor.clone(), [])
909            .await.map_err(VectorError::Other)?;
910
911        let self_wrap_client = client.clone();
912        let self_wrap_session = state::SessionGuard::capture();
913        tokio::spawn(async move {
914            if !self_wrap_session.is_valid() { return; }
915            let Ok(signer) = signer::active_signer() else { return };
916            if let Ok(wrap) = nostr_sdk::prelude::GiftWrapBuilder::new(my_public_key, rumor)
917                .finalize_async(&signer)
918                .await
919            {
920                let _ = self_wrap_client.send_event(&wrap).await;
921            }
922        });
923
924        Ok(edit_id)
925    }
926
927    /// Delete a DM you sent (NIP-09 over the retained gift-wrap keys).
928    pub async fn delete_dm(&self, message_id: &str) -> Result<deletion::DeleteOutcome> {
929        use nostr_sdk::prelude::*;
930        let rumor_id = EventId::from_hex(message_id).map_err(|e| VectorError::Nostr(e.to_string()))?;
931        deletion::delete_own_dm(&rumor_id).await.map_err(VectorError::Other)
932    }
933
934    /// Get chats from the in-memory state.
935    pub async fn get_chats(&self) -> Vec<SerializableChat> {
936        let state = state::STATE.lock().await;
937        state.chats.iter()
938            .map(|c| c.to_serializable_with_last_n(1, &state.interner))
939            .collect()
940    }
941
942    /// Get messages for a chat (paginated).
943    pub async fn get_messages(&self, chat_id: &str, limit: usize, offset: usize) -> Vec<Message> {
944        let state = state::STATE.lock().await;
945        if let Some(chat) = state.get_chat(chat_id) {
946            let msgs = chat.get_all_messages(&state.interner);
947            let start = offset.min(msgs.len());
948            let end = (offset + limit).min(msgs.len());
949            msgs[start..end].to_vec()
950        } else {
951            Vec::new()
952        }
953    }
954
955    /// Get a profile by npub.
956    pub async fn get_profile(&self, npub: &str) -> Option<SlimProfile> {
957        let state = state::STATE.lock().await;
958        state.get_profile(npub)
959            .map(|p| SlimProfile::from_profile(p, &state.interner))
960    }
961
962    /// Fetch a profile's metadata and status from relays.
963    pub async fn load_profile(&self, npub: &str) -> bool {
964        profile::sync::load_profile(npub.to_string(), &NoOpProfileSyncHandler).await
965    }
966
967    /// Update the current user's profile metadata and broadcast to relays.
968    pub async fn update_profile(&self, name: &str, avatar: &str, banner: &str, about: &str) -> bool {
969        profile::sync::update_profile(
970            name.to_string(), avatar.to_string(), banner.to_string(), about.to_string(),
971            &NoOpProfileSyncHandler,
972        ).await
973    }
974
975    /// Like [`update_profile`](Self::update_profile) but marks the profile as a bot (`bot: true` in
976    /// the metadata). The SDK uses this for every bot; build human clients on `update_profile`.
977    pub async fn update_bot_profile(&self, name: &str, avatar: &str, banner: &str, about: &str) -> bool {
978        profile::sync::update_bot_profile(
979            name.to_string(), avatar.to_string(), banner.to_string(), about.to_string(),
980            &NoOpProfileSyncHandler,
981        ).await
982    }
983
984    /// Update the current user's status and broadcast to relays.
985    pub async fn update_status(&self, status: &str) -> bool {
986        profile::sync::update_status(status.to_string()).await
987    }
988
989    /// Upload an image file to Blossom **unencrypted** and return its public URL — for avatars,
990    /// banners, and other images other clients must fetch directly. (The opposite of
991    /// [`send_file`](Self::send_file)'s encrypted attachments.) Pass the URL to [`update_profile`].
992    ///
993    /// [`update_profile`]: Self::update_profile
994    pub async fn upload_public_image(&self, file_path: &str) -> Result<String> {
995        let path = std::path::Path::new(file_path);
996        let bytes = std::fs::read(path).map_err(VectorError::Io)?;
997        if bytes.is_empty() {
998            return Err(VectorError::Other("Empty image file".into()));
999        }
1000        let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("bin").to_lowercase();
1001        let mime = crate::crypto::mime_from_extension(&extension);
1002        let _client = state::nostr_client().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
1003        let signer = crate::signer::active_signer()
1004            .map_err(|e| VectorError::Other(format!("Signer unavailable: {e}")))?;
1005        let servers = crate::blossom_servers::compute_enabled_servers();
1006        if servers.is_empty() {
1007            return Err(VectorError::Other("No Blossom servers configured".into()));
1008        }
1009        // Avatars/banners run larger than emojis (up to ~1MB), so give a more generous
1010        // 20s idle window before treating a silent server as dead and failing over.
1011        crate::blossom::upload_blob_with_failover(
1012            signer,
1013            servers,
1014            std::sync::Arc::new(bytes),
1015            Some(mime),
1016            Some(std::time::Duration::from_secs(20)),
1017        )
1018        .await
1019        .map_err(VectorError::Other)
1020    }
1021
1022    /// Block a user by npub.
1023    pub async fn block_user(&self, npub: &str) -> bool {
1024        profile::sync::block_user(npub.to_string(), &NoOpProfileSyncHandler).await
1025    }
1026
1027    /// Unblock a user by npub.
1028    pub async fn unblock_user(&self, npub: &str) -> bool {
1029        profile::sync::unblock_user(npub.to_string(), &NoOpProfileSyncHandler).await
1030    }
1031
1032    /// Set a nickname for a profile.
1033    pub async fn set_nickname(&self, npub: &str, nickname: &str) -> bool {
1034        profile::sync::set_nickname(npub.to_string(), nickname.to_string(), &NoOpProfileSyncHandler).await
1035    }
1036
1037    /// Get all blocked profiles.
1038    pub async fn get_blocked_users(&self) -> Vec<SlimProfile> {
1039        profile::sync::get_blocked_users().await
1040    }
1041
1042    /// Queue a profile for background sync.
1043    pub fn queue_profile_sync(&self, npub: &str, priority: SyncPriority) {
1044        profile::sync::queue_profile_sync(npub.to_string(), priority, false);
1045    }
1046
1047    /// Get the current user's npub.
1048    pub fn my_npub(&self) -> Option<String> {
1049        state::my_public_key()
1050            .and_then(|pk| ToBech32::to_bech32(&pk).ok())
1051    }
1052
1053    // === Communities (headless) ===
1054    // The GUI's Tauri commands carry optimistic-echo + emit machinery a headless client
1055    // doesn't need; these are the lean equivalents over the same `community::service` layer,
1056    // so a CLI / agent can join, read, post, and sync a Community.
1057
1058    /// List every Community held locally (owned or joined), each with its channels.
1059    pub async fn list_communities(&self) -> Vec<serde_json::Value> {
1060        use crate::community::ConcordProtocol;
1061        let ids = crate::db::community::list_community_ids().unwrap_or_default();
1062        let mut out = Vec::new();
1063        for id in ids {
1064            // Dual-stack: dispatch each held community by its stored protocol.
1065            match crate::db::community::community_protocol(&id).ok().flatten() {
1066                Some(ConcordProtocol::V2) => {
1067                    if let Ok(Some(c)) = crate::db::community::load_community_v2(&id) {
1068                        let me = state::my_public_key();
1069                        let is_owner = me.is_some_and(|m| c.owner().is_ok_and(|o| o == m));
1070                        out.push(serde_json::json!({
1071                            "community_id": crate::simd::hex::bytes_to_hex_32(&c.identity.community_id.0),
1072                            "version": 2,
1073                            "name": c.name,
1074                            "description": c.description,
1075                            "is_owner": is_owner,
1076                            "channels": c.channels.iter()
1077                                .map(|ch| serde_json::json!({ "channel_id": crate::simd::hex::bytes_to_hex_32(&ch.id.0), "name": ch.name, "private": ch.private }))
1078                                .collect::<Vec<_>>(),
1079                        }));
1080                    }
1081                }
1082                _ => {
1083                    if let Ok(Some(c)) = crate::db::community::load_community(&id) {
1084                        out.push(serde_json::json!({
1085                            "community_id": c.id.to_hex(),
1086                            "version": 1,
1087                            "name": c.name,
1088                            "description": c.description,
1089                            "is_owner": crate::community::service::is_proven_owner(&c),
1090                            "channels": c.channels.iter()
1091                                .map(|ch| serde_json::json!({ "channel_id": ch.id.to_hex(), "name": ch.name }))
1092                                .collect::<Vec<_>>(),
1093                        }));
1094                    }
1095                }
1096            }
1097        }
1098        out
1099    }
1100
1101    /// Create a fresh **Concord v2** community owned by the local identity (the
1102    /// SDK's default; the GUI's `create_community` stays v1 during the migration
1103    /// window). Mints the self-certifying id + genesis, persists, publishes, and
1104    /// registers each channel as a chat. Returns a `version: 2` JSON summary.
1105    pub async fn create_community_v2(&self, name: &str) -> Result<serde_json::Value> {
1106        use crate::community::{v2::service as v2, transport::LiveTransport};
1107        let relays: Vec<String> = crate::state::active_trusted_relays()
1108            .await
1109            .iter()
1110            .map(|s| s.to_string())
1111            .collect();
1112        if relays.is_empty() {
1113            return Err(VectorError::Other("no relays available to host the Community".into()));
1114        }
1115        let session = state::SessionGuard::capture();
1116        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1117        let community = v2::create_community(&transport, name, relays, None)
1118            .await
1119            .map_err(VectorError::Other)?;
1120        self.register_v2_chats(&community, &session).await;
1121        // Start streaming this community's planes right away.
1122        if let Some(client) = state::nostr_client() {
1123            crate::community::v2::realtime::refresh_subscription(&client).await;
1124        }
1125        Ok(Self::v2_summary(&community))
1126    }
1127
1128    /// If `channel_id` belongs to a locally-held **v2** community, its
1129    /// `CommunityId`; `Ok(None)` for a v1 channel or unknown. The routing key for
1130    /// every dual-stack message op — a DB read error PROPAGATES (fail-closed)
1131    /// instead of silently routing a v2 channel down the v1 path on a transient
1132    /// failure.
1133    fn v2_community_for_channel(&self, channel_id: &str) -> Result<Option<crate::community::CommunityId>> {
1134        use crate::community::ConcordProtocol;
1135        let Some(cid_hex) = crate::db::community::community_id_for_channel(channel_id).map_err(VectorError::Other)? else {
1136            return Ok(None);
1137        };
1138        let cid = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid_hex));
1139        Ok(match crate::db::community::community_protocol(&cid).map_err(VectorError::Other)? {
1140            Some(ConcordProtocol::V2) => Some(cid),
1141            _ => None,
1142        })
1143    }
1144
1145    /// The `version: 2` JSON summary the SDK/facade hands back for a v2 community.
1146    fn v2_summary(community: &crate::community::v2::community::CommunityV2) -> serde_json::Value {
1147        let me = state::my_public_key();
1148        let is_owner = me.is_some_and(|m| community.owner().is_ok_and(|o| o == m));
1149        serde_json::json!({
1150            "community_id": crate::simd::hex::bytes_to_hex_32(&community.identity.community_id.0),
1151            "version": 2,
1152            "name": community.name,
1153            "description": community.description,
1154            "is_owner": is_owner,
1155            "channels": community.channels.iter()
1156                .map(|c| serde_json::json!({ "channel_id": crate::simd::hex::bytes_to_hex_32(&c.id.0), "name": c.name, "private": c.private }))
1157                .collect::<Vec<_>>(),
1158        })
1159    }
1160
1161    /// Register each of a v2 community's channels as a chat row (so it surfaces in
1162    /// the chat list / `communities()`), mirroring the v1 create path. `session`
1163    /// is captured by the caller BEFORE its network I/O, so this STATE write is
1164    /// skipped if the account swapped mid-flight (else we'd write A's community
1165    /// into B's in-memory chats).
1166    pub async fn register_v2_chats(&self, community: &crate::community::v2::community::CommunityV2, session: &state::SessionGuard) {
1167        register_v2_chats_inner(community, session).await
1168    }
1169}
1170
1171/// Free-function body of [`VectorCore::register_v2_chats`] — also the migration finalize's
1172/// chat stamp (it runs from a spawned task with no facade handle; only globals are touched).
1173pub(crate) async fn register_v2_chats_inner(community: &crate::community::v2::community::CommunityV2, session: &state::SessionGuard) {
1174    let owner_npub = community.owner().ok().and_then(|p| ToBech32::to_bech32(&p).ok());
1175        let me = state::my_public_key();
1176        let is_owner = me.is_some_and(|m| community.owner().is_ok_and(|o| o == m));
1177        let id_hex = crate::simd::hex::bytes_to_hex_32(&community.identity.community_id.0);
1178        // The chat list shows ONE row per community — the primary channel under the
1179        // community's metadata (v1-group parity; multi-channel UI is a later cut).
1180        let Some(primary) = community.primary_channel() else { return };
1181        let primary_hex = crate::simd::hex::bytes_to_hex_32(&primary.id.0);
1182        // Every channel gets a real chat row carrying its own name plus the community's
1183        // primary id. The chat list still shows ONE row per community (it renders only the
1184        // primary), but the sibling rows are now addressable, which is what lets the UI
1185        // reach a multi-channel community's other channels.
1186        let slims = {
1187            let mut st = state::STATE.lock().await;
1188            if !session.is_valid() {
1189                return; // account swapped during the join/create — don't write into the new one.
1190            }
1191            let mut slims = Vec::new();
1192            for ch in &community.channels {
1193                let ch_hex = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
1194                st.upsert_community_chat(
1195                    &ch_hex,
1196                    &community.name,
1197                    community.description.as_deref().unwrap_or(""),
1198                    &id_hex,
1199                    is_owner,
1200                    community.icon.is_some(),
1201                    owner_npub.as_deref(),
1202                    Some(community.created_at_ms),
1203                    community.dissolved,
1204                    crate::community::ConcordProtocol::V2,
1205                    &ch.name,
1206                    &primary_hex,
1207                );
1208                if let Some(chat) = st.chats.iter().find(|c| c.id == ch_hex) {
1209                    slims.push(crate::db::chats::SlimChatDB::from_chat(chat, &st.interner));
1210                }
1211            }
1212            slims
1213        };
1214        // Persist the rows so a fresh boot reloads each channel's name/metadata
1215        // instead of the bare auto-created anchor. Session re-check: don't write
1216        // account A's rows into a swapped-in account B's DB.
1217        if !session.is_valid() {
1218            return;
1219        }
1220        for slim in &slims {
1221            let _ = crate::db::chats::save_slim_chat(slim);
1222        }
1223}
1224
1225impl VectorCore {
1226    /// Join a Community from a public invite URL (`vectorapp.io/invite#...`). Fetches the
1227    /// token-encrypted bundle, persists the member-view Community, and registers its channels
1228    /// as chats. Returns a JSON summary.
1229    pub async fn join_community(&self, invite_url: &str) -> Result<serde_json::Value> {
1230        use crate::community::{public_invite, service, transport::LiveTransport};
1231        // Dual-stack: a v2 link is `…/invite/<naddr>#<fragment>` (a naddr in the
1232        // path); a v1 link is `…/invite#<base64url>` (fragment only). Try the v2
1233        // parser first — it only succeeds on the v2 shape — then fall through to v1.
1234        if crate::community::v2::invite::parse_invite_link(invite_url).is_ok() {
1235            let session = state::SessionGuard::capture();
1236            let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1237            let community = crate::community::v2::service::accept_public_link(&transport, invite_url)
1238                .await
1239                .map_err(VectorError::Other)?;
1240            self.register_v2_chats(&community, &session).await;
1241            if let Some(client) = state::nostr_client() {
1242                crate::community::v2::realtime::refresh_subscription(&client).await;
1243            }
1244            // Seed the membership store post-join. With a live listen the follow
1245            // worker does it (and SURFACES the folded joins as presence lines —
1246            // the joiner sees the room's history, own join included); headless
1247            // callers seed directly (membership only, no feed to surface).
1248            if crate::community::v2::realtime::follow_worker_running() {
1249                crate::community::v2::realtime::enqueue_follow(community.id());
1250            } else {
1251                let seed_session = state::SessionGuard::capture();
1252                let seed_community = community.clone();
1253                tokio::spawn(async move {
1254                    if !seed_session.is_valid() {
1255                        return;
1256                    }
1257                    let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(20));
1258                    if matches!(
1259                        crate::community::v2::service::sync_guestbook(&transport, &seed_community, &seed_session).await,
1260                        Ok(fresh) if !fresh.is_empty()
1261                    ) {
1262                        let cid_hex = crate::simd::hex::bytes_to_hex_32(&seed_community.id().0);
1263                        emit_event("community_refreshed", &serde_json::json!({ "community_id": cid_hex }));
1264                    }
1265                });
1266            }
1267            return Ok(Self::v2_summary(&community));
1268        }
1269        let (relays, token) = public_invite::parse_invite_url(invite_url)
1270            .map_err(|e| VectorError::Other(e.to_string()))?;
1271        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1272        let bundle = service::fetch_public_invite(&transport, &relays, &token)
1273            .await
1274            .map_err(VectorError::Other)?;
1275        let now = std::time::SystemTime::now()
1276            .duration_since(std::time::UNIX_EPOCH)
1277            .map(|d| d.as_secs())
1278            .unwrap_or(0);
1279        // Post-timelock door: a FRESH v1 join needs a migration carrier (the v2 on-ramp)
1280        // or it is refused. Decode-only view — nothing persists unless the gate passes.
1281        let probe_view = crate::community::invite::accept_invite(&bundle.join).map_err(VectorError::Other)?;
1282        crate::community::migration::gate_fresh_v1_join(&transport, &probe_view, now)
1283            .await
1284            .map_err(VectorError::Other)?;
1285        let community = service::accept_public_invite(&bundle, now).map_err(VectorError::Other)?;
1286        // Attribute our join presence to the link we used (creator + label) so the owner's per-link
1287        // counter ticks. Mirrors the desktop public-join path.
1288        let attribution = bundle.creator_npub.clone().map(|by| (by, bundle.label.clone()));
1289        self.finalize_member_join(community, &transport, attribution).await
1290    }
1291
1292    /// List the parked private invites (giftwrapped) awaiting acceptance. Each entry is the
1293    /// community id, its name (from the stored bundle), and the inviter's npub.
1294    pub fn list_pending_invites(&self) -> Result<Vec<serde_json::Value>> {
1295        let rows = crate::db::community::list_pending_invites().map_err(VectorError::Other)?;
1296        Ok(rows.iter().map(|p| {
1297            // A v2 bundle carries owner_salt/community_root and self-certifies its
1298            // owner; a successful (validating) v2 parse means the modern protocol.
1299            if let Ok(v2) = crate::community::v2::invite::CommunityInvite::from_bundle_json(&p.bundle_json) {
1300                serde_json::json!({
1301                    "community_id": p.community_id,
1302                    "name": v2.name,
1303                    "inviter_npub": p.inviter_npub,
1304                    "version": 2,
1305                })
1306            } else {
1307                let name = crate::community::invite::CommunityInvite::from_json(&p.bundle_json)
1308                    .ok().map(|i| i.name).unwrap_or_default();
1309                serde_json::json!({
1310                    "community_id": p.community_id,
1311                    "name": name,
1312                    "inviter_npub": p.inviter_npub,
1313                    "version": 1,
1314                })
1315            }
1316        }).collect())
1317    }
1318
1319    /// Accept a PARKED private invite by community id: rebuild the member-view Community from the stored
1320    /// bundle, finalize the join exactly like a public link, then drop the pending row. Mirrors the
1321    /// desktop's consent-then-join for an invite delivered over a gift wrap.
1322    pub async fn accept_pending_invite(&self, community_id: &str) -> Result<serde_json::Value> {
1323        use crate::community::transport::LiveTransport;
1324        let bundle_json = crate::db::community::get_pending_invite(community_id)
1325            .map_err(VectorError::Other)?
1326            .ok_or_else(|| VectorError::Other(format!("no pending invite for {community_id}")))?;
1327        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1328
1329        // Dual-stack: a validating v2 bundle parse means a v2 Direct Invite.
1330        if crate::community::v2::invite::CommunityInvite::from_bundle_json(&bundle_json).is_ok() {
1331            let session = state::SessionGuard::capture();
1332            // The inviter's hex (parked at receive) attributes the Guestbook Join.
1333            let inviter = crate::db::community::list_pending_invites()
1334                .ok()
1335                .and_then(|rows| rows.into_iter().find(|p| p.community_id == community_id).map(|p| p.inviter_npub));
1336            // On failure the parked row is LEFT INTACT for retry — we must NOT auto-delete
1337            // on a verify failure: the multi-relay transport launders an unreachable-relay
1338            // error into an empty fetch, which yields the same "could not verify" as a
1339            // forged root (and a control-plane flood does too), so an auto-delete would
1340            // erase a GENUINE invite on a transient blip or an attacker's flood. A
1341            // pre-planted forged-root bundle (deferred protocol residual) is instead
1342            // cleared by the user declining it.
1343            let community = crate::community::v2::service::accept_parked_invite(&transport, &bundle_json, inviter.as_deref())
1344                .await
1345                .map_err(VectorError::Other)?;
1346            if !session.is_valid() {
1347                return Err(VectorError::Other("account changed during join".into()));
1348            }
1349            self.register_v2_chats(&community, &session).await;
1350            if let Some(client) = state::nostr_client() {
1351                crate::community::v2::realtime::refresh_subscription(&client).await;
1352            }
1353            crate::community::v2::realtime::enqueue_follow(community.id());
1354            let _ = crate::db::community::delete_pending_invite(community_id);
1355            return Ok(Self::v2_summary(&community));
1356        }
1357
1358        // v1 route.
1359        use crate::community::invite::{accept_invite, CommunityInvite};
1360        let invite = CommunityInvite::from_json(&bundle_json).map_err(VectorError::Other)?;
1361        let community = accept_invite(&invite).map_err(VectorError::Other)?;
1362        // Post-timelock door: a FRESH v1 join needs a migration carrier (the v2 on-ramp) or it
1363        // is refused — before finalize persists anything. The migrated fence inside
1364        // finalize_member_join still wins for held communities.
1365        let now = std::time::SystemTime::now()
1366            .duration_since(std::time::UNIX_EPOCH)
1367            .map(|d| d.as_secs())
1368            .unwrap_or(0);
1369        crate::community::migration::gate_fresh_v1_join(&transport, &community, now)
1370            .await
1371            .map_err(VectorError::Other)?;
1372        // Private invites carry no public-link label; the inviter attribution metric is link-only.
1373        let summary = self.finalize_member_join(community, &transport, None).await?;
1374        let _ = crate::db::community::delete_pending_invite(community_id);
1375        Ok(summary)
1376    }
1377
1378    /// Shared finalization for joining a Community as a member — public link OR accepted private invite.
1379    /// Walks any base rekey, folds the LATEST control plane (so the joiner sees current metadata, not
1380    /// the bundle's genesis snapshot), refuses if banned, registers the channels as chats, and announces
1381    /// presence. Returns the JSON summary.
1382    pub(crate) async fn finalize_member_join<T: crate::community::transport::Transport + ?Sized>(
1383        &self,
1384        community: crate::community::Community,
1385        transport: &T,
1386        attribution: Option<(String, Option<String>)>,
1387    ) -> Result<serde_json::Value> {
1388        use crate::community::service;
1389        // Migration fence: if this v1 community already flipped to v2, a stale parked
1390        // invite or a lingering link must NOT re-run `save_community` — its blind UPSERT would
1391        // re-parent the stitched channel rows back to v1 with v1 keys (the catastrophic mixed
1392        // state). Short-circuit to "already upgraded"; the rows stay v2-owned. This is
1393        // `migrated_to`-aware (not a blind dissolved gate) precisely so a FRESH joiner redeeming
1394        // a still-live link stays on the open on-ramp path below.
1395        if let Ok(Some(v2)) = crate::db::community::get_migrated_to(&community.id.to_hex()) {
1396            return Ok(serde_json::json!({
1397                "community_id": v2,
1398                "version": 2,
1399                "migrated": true,
1400            }));
1401        }
1402        // Persist the member-view row up front: the catch-up, the control fold, and chat registration all
1403        // read it back from the DB. A private bundle (unlike a public one with a preview) arrives with no
1404        // display metadata, so nothing else would have saved it. UPSERT — re-saving a public join is a no-op.
1405        crate::db::community::save_community(&community).map_err(VectorError::Other)?;
1406        // The bundle's root can predate a base rotation, so walk any rekey first (no-op if none) — then
1407        // re-load so the control fold + registration happen at the CURRENT epoch.
1408        if let Ok(c) = service::catch_up_server_root(transport, &community).await {
1409            if c.removed {
1410                let _ = crate::db::community::delete_community(&community.id.to_hex());
1411                return Err(VectorError::Other("you have been removed from this community".into()));
1412            }
1413        }
1414        let community = crate::db::community::load_community(&community.id)
1415            .map_err(VectorError::Other)?
1416            .unwrap_or(community);
1417        // Fold the LATEST control plane before we register anything — the joiner should see the current
1418        // name/description/roster/mode immediately, not a stale snapshot. Banlist first: an honest client
1419        // REFUSES to join if this npub is banned (and the just-saved community is torn back down).
1420        let _ = service::fetch_and_apply_control(transport, &community).await;
1421        if service::am_i_banned(&community) {
1422            let _ = crate::db::community::delete_community(&community.id.to_hex());
1423            return Err(VectorError::Other("you are banned from this community".into()));
1424        }
1425        // Re-load so the chat we register + the summary we return carry the freshly-folded latest metadata.
1426        let community = crate::db::community::load_community(&community.id)
1427            .map_err(VectorError::Other)?
1428            .unwrap_or(community);
1429        let owner_npub = community
1430            .owner_attestation
1431            .as_ref()
1432            .and_then(|att| crate::community::owner::verify_owner_attestation(att, &community.id.to_hex()))
1433            .and_then(|pk| ToBech32::to_bech32(&pk).ok());
1434        {
1435            let created_at_ms = crate::db::community::community_created_at_ms(&community.id);
1436            let primary_hex = community.channels.first().map(|c| c.id.to_hex()).unwrap_or_default();
1437            let mut st = state::STATE.lock().await;
1438            for ch in &community.channels {
1439                st.upsert_community_chat(
1440                    &ch.id.to_hex(),
1441                    &community.name,
1442                    community.description.as_deref().unwrap_or(""),
1443                    &community.id.to_hex(),
1444                    crate::community::service::is_proven_owner(&community),
1445                    community.icon.is_some(),
1446                    owner_npub.as_deref(),
1447                    created_at_ms,
1448                    community.dissolved,
1449                    crate::community::ConcordProtocol::V1,
1450                    &ch.name,
1451                    &primary_hex,
1452                );
1453            }
1454        }
1455        // Best-effort join announcement (kind 3306) into the primary channel so honest peers
1456        // see us in their member list even before we post. Failure must not fail the join.
1457        if let Some(primary) = community.channels.first() {
1458            let _ = service::publish_presence(transport, &community, primary, true, attribution).await;
1459        }
1460        Ok(serde_json::json!({
1461            "community_id": community.id.to_hex(),
1462            "version": 1,
1463            "name": community.name,
1464            "channels": community.channels.iter()
1465                .map(|c| serde_json::json!({ "channel_id": c.id.to_hex(), "name": c.name }))
1466                .collect::<Vec<_>>(),
1467        }))
1468    }
1469
1470    /// Create a Community (single "general" channel) on the default trusted relays. Signs the
1471    /// owner attestation with this identity (so the creator is the proven owner), registers the
1472    /// channel as a chat, and returns a JSON summary.
1473    pub async fn create_community(&self, name: &str) -> Result<serde_json::Value> {
1474        use crate::community::{service, transport::LiveTransport};
1475        let relays: Vec<String> = crate::state::active_trusted_relays()
1476            .await
1477            .iter()
1478            .map(|s| s.to_string())
1479            .collect();
1480        if relays.is_empty() {
1481            return Err(VectorError::Other("no relays available to host the Community".into()));
1482        }
1483        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1484        let community = service::create_community(&transport, name, "general", relays)
1485            .await
1486            .map_err(VectorError::Other)?;
1487        let owner_npub = community
1488            .owner_attestation
1489            .as_ref()
1490            .and_then(|att| crate::community::owner::verify_owner_attestation(att, &community.id.to_hex()))
1491            .and_then(|pk| ToBech32::to_bech32(&pk).ok());
1492        {
1493            let created_at_ms = crate::db::community::community_created_at_ms(&community.id);
1494            let primary_hex = community.channels.first().map(|c| c.id.to_hex()).unwrap_or_default();
1495            let mut st = state::STATE.lock().await;
1496            for ch in &community.channels {
1497                st.upsert_community_chat(
1498                    &ch.id.to_hex(),
1499                    &community.name,
1500                    community.description.as_deref().unwrap_or(""),
1501                    &community.id.to_hex(),
1502                    crate::community::service::is_proven_owner(&community),
1503                    community.icon.is_some(),
1504                    owner_npub.as_deref(),
1505                    created_at_ms,
1506                    community.dissolved,
1507                    crate::community::ConcordProtocol::V1,
1508                    &ch.name,
1509                    &primary_hex,
1510                );
1511            }
1512        }
1513        Ok(serde_json::json!({
1514            "community_id": community.id.to_hex(),
1515            "version": 1,
1516            "name": community.name,
1517            "channels": community.channels.iter()
1518                .map(|c| serde_json::json!({ "channel_id": c.id.to_hex(), "name": c.name }))
1519                .collect::<Vec<_>>(),
1520        }))
1521    }
1522
1523    /// Mint a public invite link for a Community this identity owns. Returns the shareable URL.
1524    pub async fn create_public_invite(&self, community_id: &str) -> Result<String> {
1525        use crate::community::{service, transport::LiveTransport, CommunityId};
1526        if community_id.len() != 64 {
1527            return Err(VectorError::Other("malformed community id".into()));
1528        }
1529        let cid = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
1530        // Dual-stack: mint a v2 link for a v2 community (naddr#fragment).
1531        if let Some(Some(crate::community::ConcordProtocol::V2)) =
1532            crate::db::community::community_protocol(&cid).ok()
1533        {
1534            let community = crate::db::community::load_community_v2(&cid)
1535                .map_err(VectorError::Other)?
1536                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
1537            let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1538            // v2 `build_invite_url` appends its own `/invite/<naddr>`, so pass the
1539            // bare domain (strip the `/invite` the v1 constant carries).
1540            let base = crate::community::public_invite::INVITE_URL_BASE.trim_end_matches("/invite");
1541            let minted = crate::community::v2::service::mint_public_link(&transport, &community, base, None, None)
1542                .await
1543                .map_err(VectorError::Other)?;
1544            return Ok(minted.url);
1545        }
1546        let community = crate::db::community::load_community(&CommunityId(
1547            crate::simd::hex::hex_to_bytes_32(community_id),
1548        ))
1549        .map_err(VectorError::Other)?
1550        .ok_or_else(|| VectorError::Other("community not found".into()))?;
1551        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1552        let (_token, url) = service::create_public_invite(&transport, &community, None, None)
1553            .await
1554            .map_err(VectorError::Other)?;
1555        Ok(url)
1556    }
1557
1558    /// Send a PRIVATE invite: gift-wrap this Community's invite bundle directly to an npub over a NIP-17
1559    /// DM (the same transport as a regular DM). The invitee parks it pending consent (accept_pending_invite).
1560    /// Requires CREATE_INVITE; a banned npub can't be re-invited. Returns the wrap's event id + relays.
1561    pub async fn invite_to_community(&self, community_id: &str, invitee_npub: &str) -> Result<serde_json::Value> {
1562        use crate::community::{service, CommunityId};
1563        use crate::sending::{send_rumor_dm, NoOpSendCallback, SendCallback, SendConfig};
1564
1565        let session = crate::state::SessionGuard::capture();
1566        let my_pk = crate::state::my_public_key()
1567            .ok_or_else(|| VectorError::Other("Public key not set".into()))?;
1568
1569        if community_id.len() != 64 {
1570            return Err(VectorError::Other("malformed community id".into()));
1571        }
1572        let cid = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
1573        // Dual-stack: a v2 community sends a Direct Invite (3313 giftwrap).
1574        // DELIBERATELY ungated, unlike v1's CREATE_INVITE + banlist pre-check: a
1575        // Direct Invite is an ungateable key handoff (CORD-05 §6 — "any keyholder
1576        // can whisper keys"), so any member may extend one; the real access cut is
1577        // the rekey, not a permission on inviting.
1578        if let Some(Some(crate::community::ConcordProtocol::V2)) =
1579            crate::db::community::community_protocol(&cid).ok()
1580        {
1581            let recipient = nostr_sdk::prelude::PublicKey::parse(invitee_npub)
1582                .map_err(|e| VectorError::Other(format!("bad invitee npub: {e}")))?;
1583            let client = crate::state::nostr_client().ok_or_else(|| VectorError::Other("Not connected".into()))?;
1584            // Gift-wrap the 3313 Direct-Invite rumor (the bundle JSON) to the RECIPIENT'S
1585            // inbox relays (kind-10050) — a not-yet-member sees it on their DM sub;
1586            // the community relays wouldn't reach them. `#k=3313` per CORD-05 §6.
1587            //
1588            // Load + snapshot UNDER the rotation lock: a bundle minted while a Ban's
1589            // refound is mid-rotation carries the root being buried, and its joiner
1590            // lands on a dead epoch only to self-evict on the rekey exclusion.
1591            let bundle = {
1592                let lock = crate::community::v2::realtime::follow_lock(&cid);
1593                let _rotation = lock.lock().await;
1594                let community = crate::db::community::load_community_v2(&cid)
1595                    .map_err(VectorError::Other)?
1596                    .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
1597                crate::community::v2::service::bundle_of(&community, Some(my_pk), None, None)
1598            };
1599            let bundle_json = serde_json::to_string(&bundle).map_err(|e| VectorError::Other(e.to_string()))?;
1600            // Same 24h NIP-40 expiry as v1 (invite::DIRECT_INVITE_EXPIRY_SECS): a bundle is
1601            // live key material for a community that keeps rotating, so it must not linger.
1602            let expires_at = nostr_sdk::prelude::Timestamp::now().as_secs()
1603                + crate::community::invite::DIRECT_INVITE_EXPIRY_SECS;
1604            let expiry_tag = nostr_sdk::prelude::Tag::expiration(nostr_sdk::prelude::Timestamp::from_secs(expires_at));
1605            let rumor = nostr_sdk::prelude::EventBuilder::new(
1606                nostr_sdk::prelude::Kind::Custom(crate::community::v2::kind::DIRECT_INVITE),
1607                bundle_json,
1608            )
1609            .tag(expiry_tag.clone())
1610            .finalize_unsigned_with_id(my_pk);
1611            let k_tag = nostr_sdk::prelude::Tag::custom(
1612                "k",
1613                [crate::community::v2::kind::DIRECT_INVITE.to_string()],
1614            );
1615            if !session.is_valid() {
1616                return Err(VectorError::Other("account changed".into()));
1617            }
1618            crate::inbox_relays::send_gift_wrap(&client, &recipient, rumor, [k_tag, expiry_tag])
1619                .await
1620                .map_err(VectorError::Other)?;
1621            return Ok(serde_json::json!({ "invited": invitee_npub, "version": 2 }));
1622        }
1623        let community = crate::db::community::load_community(&CommunityId(
1624            crate::simd::hex::hex_to_bytes_32(community_id),
1625        ))
1626        .map_err(VectorError::Other)?
1627        .ok_or_else(|| VectorError::Other("community not found".into()))?;
1628
1629        if !service::caller_has_permission(&community, crate::community::roles::Permissions::CREATE_INVITE) {
1630            return Err(VectorError::Other("You need the create-invite permission to invite someone".into()));
1631        }
1632        let invitee_hex = nostr_sdk::prelude::PublicKey::parse(invitee_npub)
1633            .map_err(|_| VectorError::Other("invalid npub".into()))?
1634            .to_hex();
1635        if crate::db::community::get_community_banlist(community_id)
1636            .map_err(VectorError::Other)?
1637            .iter()
1638            .any(|b| b == &invitee_hex)
1639        {
1640            return Err(VectorError::Other("That member is banned from this community and can't be invited".into()));
1641        }
1642
1643        // The bundle is built from purely local state; bail if the account swapped before the gift-wrap.
1644        if !session.is_valid() {
1645            return Err(VectorError::Other("account changed during invite".into()));
1646        }
1647
1648        let now = nostr_sdk::prelude::Timestamp::now().as_secs();
1649        let rumor = crate::community::invite::build_invite_rumor(&community, my_pk, now)
1650            .map_err(VectorError::Other)?;
1651        let pending_id = format!("community-invite-{}", community_id);
1652        // self_send=false: the owner already holds the Community; the inbound guard would drop the echo.
1653        let config = SendConfig { self_send: false, ..SendConfig::gui() };
1654        let callback: Arc<dyn SendCallback> = Arc::new(NoOpSendCallback);
1655
1656        let result = send_rumor_dm(invitee_npub, &pending_id, rumor, &config, callback)
1657            .await
1658            .map_err(VectorError::Other)?;
1659
1660        Ok(serde_json::json!({
1661            "community_id": community_id,
1662            "invitee": invitee_npub,
1663            "wrap_event_id": result.event_id,
1664        }))
1665    }
1666
1667    /// The public invite links this account minted for a Community (to list + revoke). Each carries
1668    /// the hex `token` (the link secret) needed by [`Self::revoke_public_invite`]. A local read for
1669    /// both protocols — links minted on this device (a v2 mint also syncs the cross-device 13303
1670    /// record; v2 `join_count` is not yet tracked and is always 0).
1671    pub fn list_public_invites(&self, community_id: &str) -> Result<Vec<crate::db::community::PublicInviteRecord>> {
1672        crate::db::community::list_public_invites(community_id).map_err(VectorError::Other)
1673    }
1674
1675    /// Revoke a public invite link by its hex token. Retiring the LAST active link flips the Community to
1676    /// Private, which re-founds (rotates the base key + every channel key) to cut link-joined lurkers.
1677    /// Idempotent: a token this account doesn't hold is a no-op. Needs a local key when the revoke triggers
1678    /// the privatize rekey (a bunker account can't rotate).
1679    pub async fn revoke_public_invite(&self, community_id: &str, token: &str) -> Result<()> {
1680        use crate::community::{service, transport::LiveTransport, CommunityId};
1681        if community_id.len() != 64 {
1682            return Err(VectorError::Other("malformed community id".into()));
1683        }
1684        let cid = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
1685        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(20));
1686        // Dual-stack: a v2 link is retired by its 16-byte token hex (re-post the
1687        // coordinate as a tombstone + tombstone the 13303 entry + refresh the Registry).
1688        if let Some(Some(crate::community::ConcordProtocol::V2)) = crate::db::community::community_protocol(&cid).ok() {
1689            let community = crate::db::community::load_community_v2(&cid)
1690                .map_err(VectorError::Other)?
1691                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
1692            return crate::community::v2::service::revoke_public_link(&transport, &community, token)
1693                .await
1694                .map_err(VectorError::Other);
1695        }
1696        let token_bytes = crate::simd::hex::hex_to_bytes_32(token);
1697        let community = crate::db::community::load_community(&cid)
1698            .map_err(VectorError::Other)?
1699            .ok_or_else(|| VectorError::Other("community not found".into()))?;
1700        service::revoke_public_invite(&transport, &community, &token_bytes)
1701            .await
1702            .map_err(VectorError::Other)
1703    }
1704
1705    /// Post a text message to a Community channel. Returns the message id (the inner id).
1706    pub async fn send_community_message(
1707        &self,
1708        channel_id: &str,
1709        content: &str,
1710        replied_to: Option<&str>,
1711    ) -> Result<String> {
1712        use crate::community::{envelope, inbound, service, transport::LiveTransport};
1713        // Dual-stack: route by the owning community's stored protocol.
1714        if let Some(id) = self.v2_community_for_channel(channel_id)? {
1715            let community = crate::db::community::load_community_v2(&id)
1716                .map_err(VectorError::Other)?
1717                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
1718            let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
1719            let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1720            // The NIP-C7 q tag's author slot is a SHOULD — best-effort from the
1721            // held message, empty (= unknown) when the parent isn't in memory.
1722            let reply = match replied_to.filter(|r| !r.is_empty()) {
1723                Some(parent_id) => {
1724                    let author_hex = {
1725                        let st = state::STATE.lock().await;
1726                        st.find_message(parent_id)
1727                            .and_then(|(_, m)| m.npub.as_deref().and_then(|n| nostr_sdk::prelude::PublicKey::parse(n).ok()))
1728                            .map(|pk| pk.to_hex())
1729                            .unwrap_or_default()
1730                    };
1731                    Some((parent_id.to_string(), author_hex))
1732                }
1733                None => None,
1734            };
1735            let reply_ref = reply.as_ref().map(|(id, author)| (id.as_str(), author.as_str()));
1736            // NIP-30: resolve `:shortcode:` against subscribed packs so the rumor
1737            // carries `["emoji", ...]` pairs — parity with the v1 inner event.
1738            let emoji_owned = crate::emoji_packs::resolve_outbound_emoji_tags(content);
1739            let emoji_pairs: Vec<(&str, &str)> = emoji_owned.iter().map(|t| (t.shortcode.as_str(), t.url.as_str())).collect();
1740            return crate::community::v2::service::send_chat_message(&transport, &community, &ch, content, reply_ref, &emoji_pairs, vec![])
1741                .await
1742                .map_err(VectorError::Other);
1743        }
1744        let (community, channel) = self.resolve_channel(channel_id)?;
1745        let author_pk = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
1746        let reply = replied_to.filter(|r| !r.is_empty());
1747        let ms = std::time::SystemTime::now()
1748            .duration_since(std::time::UNIX_EPOCH)
1749            .map(|d| d.as_millis() as u64)
1750            .unwrap_or(0);
1751        let unsigned = envelope::build_inner_typed(
1752            author_pk,
1753            &channel.id,
1754            channel.epoch,
1755            crate::stored_event::event_kind::COMMUNITY_MESSAGE,
1756            content,
1757            ms,
1758            reply,
1759            &[],
1760        );
1761        let message_id = unsigned.id.ok_or_else(|| VectorError::Other("inner event has no id".into()))?.to_hex();
1762        let _client = state::nostr_client().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
1763        let signer = crate::signer::active_signer().map_err(|e| VectorError::Other(format!("Signer unavailable: {e}")))?;
1764        let inner = unsigned.finalize_async(&signer).await.map_err(|e| VectorError::Other(format!("sign: {e}")))?;
1765        let session = state::SessionGuard::capture();
1766        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1767        let outer = service::send_signed_message(&transport, &community, &channel, &inner)
1768            .await
1769            .map_err(VectorError::Other)?;
1770        // Local echo so get_messages reflects the send (the relay echo dedups on inner id).
1771        // A swap during the publish must not echo account A's message into account B.
1772        if !session.is_valid() {
1773            return Ok(message_id);
1774        }
1775        let echoed = {
1776            let mut st = state::STATE.lock().await;
1777            inbound::process_incoming(&mut st, &outer, &channel, &author_pk)
1778        };
1779        if let Some(inbound::IncomingEvent::NewMessage(msg)) = echoed {
1780            let _ = crate::db::events::save_message(channel_id, &msg).await;
1781        }
1782        Ok(message_id)
1783    }
1784
1785    /// Send a file to a Community channel as an encrypted attachment. Returns the message id.
1786    /// Mirrors the DM file pipeline (encrypt → Blossom upload → NIP-92 `imeta`) but publishes
1787    /// over the community transport.
1788    pub async fn send_community_file(&self, channel_id: &str, file_path: &str) -> Result<String> {
1789        use crate::community::{attachments, envelope, inbound, service, transport::LiveTransport};
1790        let path = std::path::Path::new(file_path);
1791        let bytes = std::fs::read(path).map_err(VectorError::Io)?;
1792        if bytes.is_empty() {
1793            return Err(VectorError::Other("Empty file".into()));
1794        }
1795        let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("file").to_string();
1796        let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("bin").to_lowercase();
1797
1798        // Snapshot the session BEFORE the upload: the destination below is resolved
1799        // from THIS account's DB, and the upload can outlive an account swap.
1800        let session = state::SessionGuard::capture();
1801        // Dual-stack: resolve the destination BEFORE the upload so a bad channel
1802        // fails fast (never spend an upload on an unroutable send).
1803        let v2_target = match self.v2_community_for_channel(channel_id)? {
1804            Some(id) => Some(
1805                crate::db::community::load_community_v2(&id)
1806                    .map_err(VectorError::Other)?
1807                    .ok_or_else(|| VectorError::Other("v2 community not found".into()))?,
1808            ),
1809            None => None,
1810        };
1811        let v1_target = match v2_target {
1812            Some(_) => None,
1813            None => Some(self.resolve_channel(channel_id)?),
1814        };
1815        let author_pk = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
1816
1817        let file_hash = crate::crypto::sha256_hex(&bytes);
1818        let mime = crate::crypto::mime_from_extension(&extension);
1819        let img_meta = crate::crypto::generate_image_metadata(&bytes);
1820
1821        // Save the plaintext locally (hash-keyed) so the sender previews it instantly.
1822        let download_dir = crate::db::get_download_dir();
1823        let _ = std::fs::create_dir_all(&download_dir);
1824        let local_name = if filename.is_empty() { format!("{}.{}", &file_hash, extension) } else { filename.clone() };
1825        let local_path = crate::crypto::resolve_unique_filename(&download_dir, &local_name);
1826        let _ = std::fs::write(&local_path, &bytes);
1827
1828        // Encrypt → upload to Blossom (signer reused for the envelope below).
1829        let params = crate::crypto::generate_encryption_params();
1830        let encrypted = crate::crypto::encrypt_data(&bytes, &params)?;
1831        let encrypted_size = encrypted.len() as u64;
1832
1833        let _client = state::nostr_client().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
1834        let signer = crate::signer::active_signer().map_err(|e| VectorError::Other(format!("Signer unavailable: {e}")))?;
1835        let servers = crate::blossom_servers::compute_enabled_servers();
1836        if servers.is_empty() {
1837            return Err(VectorError::Other("No Blossom servers configured".into()));
1838        }
1839        let noop_progress: crate::blossom::ProgressCallback = std::sync::Arc::new(|_, _| Ok(()));
1840        let url = crate::blossom::upload_blob_with_progress_and_failover(
1841            signer.clone(),
1842            servers,
1843            std::sync::Arc::new(encrypted),
1844            Some(mime),
1845            /* is_encrypted */ true,
1846            noop_progress,
1847            Some(3),
1848            Some(std::time::Duration::from_secs(2)),
1849            None,
1850        ).await.map_err(VectorError::Other)?;
1851
1852        let attachment = crate::types::Attachment {
1853            id: file_hash.clone(),
1854            key: params.key.clone(),
1855            nonce: params.nonce.clone(),
1856            extension: extension.clone(),
1857            name: filename.clone(),
1858            url,
1859            path: local_path.to_string_lossy().to_string(),
1860            size: encrypted_size,
1861            img_meta,
1862            downloading: false,
1863            downloaded: true,
1864            ..Default::default()
1865        };
1866        let imeta = vec![attachments::attachment_to_imeta(&attachment)];
1867
1868        // The upload straddled awaits — never publish a pre-swap destination.
1869        if !session.is_valid() {
1870            return Err(VectorError::Other("account changed during upload".into()));
1871        }
1872        // v2: the imeta rides the kind-9 rumor verbatim (NIP-92), content empty.
1873        if let Some(community) = v2_target {
1874            let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
1875            let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(30));
1876            return crate::community::v2::service::send_chat_message(&transport, &community, &ch, "", None, &[], imeta)
1877                .await
1878                .map_err(VectorError::Other);
1879        }
1880        let (community, channel) = v1_target.expect("v1 target resolved when no v2 community matched");
1881        let ms = std::time::SystemTime::now()
1882            .duration_since(std::time::UNIX_EPOCH)
1883            .map(|d| d.as_millis() as u64)
1884            .unwrap_or(0);
1885        let unsigned = envelope::build_inner_full(
1886            author_pk, &channel.id, channel.epoch,
1887            stored_event::event_kind::COMMUNITY_MESSAGE, "", ms, None, &[], &imeta,
1888        );
1889        let message_id = unsigned.id.ok_or_else(|| VectorError::Other("inner event has no id".into()))?.to_hex();
1890        let inner = unsigned.finalize_async(&signer).await.map_err(|e| VectorError::Other(format!("sign: {e}")))?;
1891        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(30));
1892        let outer = service::send_signed_message(&transport, &community, &channel, &inner)
1893            .await.map_err(VectorError::Other)?;
1894        // Local echo so get_messages reflects the send.
1895        let echoed = {
1896            let mut st = state::STATE.lock().await;
1897            inbound::process_incoming(&mut st, &outer, &channel, &author_pk)
1898        };
1899        if let Some(inbound::IncomingEvent::NewMessage(m)) = echoed {
1900            let _ = crate::db::events::save_message(channel_id, &m).await;
1901        }
1902        Ok(message_id)
1903    }
1904
1905    /// Send an ephemeral typing indicator to a Community channel.
1906    pub async fn send_community_typing(&self, channel_id: &str) -> Result<()> {
1907        use crate::community::{service, transport::LiveTransport};
1908        if let Some(id) = self.v2_community_for_channel(channel_id)? {
1909            let community = crate::db::community::load_community_v2(&id)
1910                .map_err(VectorError::Other)?
1911                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
1912            let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
1913            let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(8));
1914            return crate::community::v2::service::send_typing(&transport, &community, &ch)
1915                .await
1916                .map_err(VectorError::Other);
1917        }
1918        let (community, channel) = self.resolve_channel(channel_id)?;
1919        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(8));
1920        service::publish_typing_signal(&transport, &community, &channel)
1921            .await
1922            .map_err(VectorError::Other)
1923    }
1924
1925    /// React to a Community message. `emoji_url` carries the NIP-30 image URL for a custom
1926    /// `:shortcode:` reaction (parity with DMs).
1927    pub async fn send_community_reaction(
1928        &self,
1929        channel_id: &str,
1930        message_id: &str,
1931        emoji: &str,
1932        emoji_url: Option<&str>,
1933    ) -> Result<()> {
1934        let emoji_tags: Vec<crate::types::EmojiTag> = match emoji_url {
1935            Some(url) if emoji.starts_with(':') && emoji.ends_with(':') && emoji.len() >= 3 && !url.is_empty() => {
1936                vec![crate::types::EmojiTag { shortcode: emoji[1..emoji.len() - 1].to_string(), url: url.to_string() }]
1937            }
1938            _ => Vec::new(),
1939        };
1940        if let Some(id) = self.v2_community_for_channel(channel_id)? {
1941            let session = state::SessionGuard::capture();
1942            let community = crate::db::community::load_community_v2(&id)
1943                .map_err(VectorError::Other)?
1944                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
1945            let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
1946            let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
1947            // NIP-25 names the reacted-to author (a required `p`). STATE first, then
1948            // the persisted row (v2 history + the send echo live in the shared events
1949            // store, so this almost always resolves locally); the channel-page fetch
1950            // is the last resort for a target this device never saw.
1951            let held = {
1952                let st = state::STATE.lock().await;
1953                st.find_message(message_id)
1954                    .and_then(|(_, m)| m.npub.as_deref().and_then(|n| nostr_sdk::prelude::PublicKey::parse(n).ok()))
1955            };
1956            let held = held.or_else(|| {
1957                crate::db::events::event_author(message_id)
1958                    .ok()
1959                    .flatten()
1960                    .and_then(|n| nostr_sdk::prelude::PublicKey::parse(&n).ok())
1961            });
1962            let target_author = match held {
1963                Some(pk) => pk,
1964                None => crate::community::v2::service::fetch_channel(&transport, &community, &ch, 500)
1965                    .await
1966                    .map_err(VectorError::Other)?
1967                    .iter()
1968                    .find(|f| f.event.opened().rumor_id.to_hex() == message_id)
1969                    .map(|f| f.event.opened().author)
1970                    .ok_or_else(|| VectorError::Other("reacted-to message not found".into()))?,
1971            };
1972            // The author lookup straddled awaits against THIS account's community.
1973            if !session.is_valid() {
1974                return Err(VectorError::Other("account changed before send".into()));
1975            }
1976            let pair = emoji_tags.first().map(|t| (t.shortcode.as_str(), t.url.as_str()));
1977            // The NIP-25 `k` names the target's rumor kind. Stored rows don't keep
1978            // wire-kind fidelity yet, so a reaction to a received kind-1111 thread
1979            // reply claims `9` — Armada's fold ignores reaction `k`, and exact
1980            // threading lands with the thread-aware GUI.
1981            return crate::community::v2::service::send_reaction(
1982                &transport, &community, &ch, message_id, &target_author.to_hex(), crate::community::v2::kind::MESSAGE, emoji, pair,
1983            )
1984            .await
1985            .map(|_| ())
1986            .map_err(VectorError::Other);
1987        }
1988        self.publish_community_control(
1989            channel_id, stored_event::event_kind::COMMUNITY_REACTION, emoji, message_id, &emoji_tags,
1990        ).await
1991    }
1992
1993    /// Edit one of your own Community messages.
1994    pub async fn edit_community_message(&self, channel_id: &str, message_id: &str, new_content: &str) -> Result<()> {
1995        let emoji_tags = emoji_packs::resolve_outbound_emoji_tags(new_content);
1996        if let Some(id) = self.v2_community_for_channel(channel_id)? {
1997            let community = crate::db::community::load_community_v2(&id)
1998                .map_err(VectorError::Other)?
1999                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
2000            let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
2001            let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2002            return crate::community::v2::service::send_edit(&transport, &community, &ch, message_id, new_content)
2003                .await
2004                .map(|_| ())
2005                .map_err(VectorError::Other);
2006        }
2007        self.publish_community_control(
2008            channel_id, stored_event::event_kind::COMMUNITY_EDIT, new_content, message_id, &emoji_tags,
2009        ).await
2010    }
2011
2012    /// Delete one of your own Community messages, resolving its channel from local
2013    /// state (the GUI path). A headless v2 consumer holds no local history — use
2014    /// [`Self::delete_community_message_in`] with the channel id instead.
2015    pub async fn delete_community_message(&self, message_id: &str) -> Result<()> {
2016        let channel_id = {
2017            let st = state::STATE.lock().await;
2018            match st.find_message(message_id) {
2019                Some((chat, _)) => chat.id.clone(),
2020                None => return Err(VectorError::Other("message not found (already deleted?)".into())),
2021            }
2022        };
2023        self.delete_community_message_in(&channel_id, message_id).await
2024    }
2025
2026    /// Delete one of your own Community messages in `channel_id`: a NIP-09 relay nuke when the
2027    /// per-message key is held (v1) or the in-plane kind-5 (v2), plus a cooperative tombstone so
2028    /// peers hide it, plus best-effort attachment cleanup.
2029    pub async fn delete_community_message_in(&self, channel_id: &str, message_id: &str) -> Result<()> {
2030        use crate::community::{service, transport::LiveTransport};
2031        let session = state::SessionGuard::capture();
2032        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2033
2034        // Attachment URLs come from local state when held (a headless v2 consumer
2035        // has none — blob cleanup is then the receiving peers' concern, not ours).
2036        let attachment_urls: Vec<String> = {
2037            let st = state::STATE.lock().await;
2038            st.find_message(message_id)
2039                .map(|(_, msg)| msg.attachments.iter().flat_map(|a| a.all_urls().map(str::to_string)).collect())
2040                .unwrap_or_default()
2041        };
2042
2043        if let Some(id) = self.v2_community_for_channel(channel_id)? {
2044            // v2: the cooperative in-plane kind-5 (the wrap-ciphertext scrub needs
2045            // the ephemeral wrap key, not retained in this cut).
2046            let community = crate::db::community::load_community_v2(&id)
2047                .map_err(VectorError::Other)?
2048                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
2049            let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(&channel_id));
2050            crate::community::v2::service::send_delete(
2051                &transport, &community, &ch, message_id, crate::community::v2::kind::MESSAGE,
2052            )
2053            .await
2054            .map_err(VectorError::Other)?;
2055        } else {
2056            // Layer 1 — relay nuke against the retained per-message key (best-effort).
2057            if crate::db::community::get_message_key(message_id).map(|k| k.is_some()).unwrap_or(false) {
2058                let _ = service::delete_message(&transport, message_id).await;
2059            }
2060            // Layer 2 — cooperative tombstone so peers hide it.
2061            self.publish_community_control(
2062                &channel_id, stored_event::event_kind::COMMUNITY_DELETE, "", message_id, &[],
2063            ).await?;
2064        }
2065        // Layer 3 — best-effort attachment blob delete.
2066        if !attachment_urls.is_empty() {
2067            if let Some(_client) = state::nostr_client() {
2068                if let Ok(signer) = crate::signer::active_signer() {
2069                    crate::blossom::delete_blobs_best_effort(signer, attachment_urls);
2070                }
2071            }
2072        }
2073        // Local removal — the publishes above straddled awaits; a swap must not let this
2074        // strip the message from a swapped-in account's STATE + DB (message_id is global).
2075        if !session.is_valid() {
2076            return Ok(());
2077        }
2078        let removed_chat = {
2079            let mut st = state::STATE.lock().await;
2080            st.remove_message(message_id).map(|(cid, _)| cid)
2081        };
2082        let _ = crate::db::events::delete_event(message_id).await;
2083        traits::emit_event_json("message_removed", serde_json::json!({
2084            "id": message_id, "chat_id": removed_chat.as_deref().unwrap_or(&channel_id), "reason": "deleted",
2085        }));
2086        Ok(())
2087    }
2088
2089    /// Moderation-hide someone ELSE's community message under `MANAGE_MESSAGES`
2090    /// (CORD-04 §3/§5). Protocol-agnostic: v2 seals the same kind-5 its authors
2091    /// use, v1 publishes its 3305 tombstone; both re-derive the actor's authority
2092    /// from the signed inner against the folded Roster, so this is an authority
2093    /// claim peers verify, never a local suppression.
2094    pub async fn hide_community_message(&self, channel_id: &str, message_id: &str) -> Result<()> {
2095        use crate::community::transport::LiveTransport;
2096        let session = state::SessionGuard::capture();
2097        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2098
2099        // You can only moderate a message you can see: the author resolves from
2100        // STATE, then the store for a row that has paged out of the window.
2101        let author_npub = {
2102            let st = state::STATE.lock().await;
2103            st.find_message(message_id).and_then(|(_, m)| m.npub)
2104        };
2105        let author_npub = match author_npub {
2106            Some(n) => n,
2107            None => crate::db::events::event_author(message_id)
2108                .ok()
2109                .flatten()
2110                .ok_or_else(|| VectorError::Other("can't resolve the target message's author".into()))?,
2111        };
2112        let author = nostr_sdk::prelude::PublicKey::parse(&author_npub)
2113            .map_err(|_| VectorError::Other("target message has an unreadable author".into()))?;
2114
2115        if let Some(id) = self.v2_community_for_channel(channel_id)? {
2116            let community = crate::db::community::load_community_v2(&id)
2117                .map_err(VectorError::Other)?
2118                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
2119            let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
2120            crate::community::v2::service::moderation_delete(
2121                &transport, &community, &ch, message_id, crate::community::v2::kind::MESSAGE, &author,
2122            )
2123            .await
2124            .map_err(VectorError::Other)?;
2125        } else {
2126            let cid = crate::db::community::community_id_for_channel(channel_id)
2127                .map_err(VectorError::Other)?
2128                .ok_or_else(|| VectorError::Other("unknown community channel".into()))?;
2129            let community = crate::db::community::load_community(&crate::community::CommunityId(
2130                crate::simd::hex::hex_to_bytes_32(&cid),
2131            ))
2132            .map_err(VectorError::Other)?
2133            .ok_or_else(|| VectorError::Other("community not found".into()))?;
2134            let channel = community
2135                .channels
2136                .iter()
2137                .find(|c| c.id.to_hex() == channel_id)
2138                .cloned()
2139                .ok_or_else(|| VectorError::Other("channel not found in community".into()))?;
2140            crate::community::service::publish_owner_hide(&transport, &community, &channel, message_id)
2141                .await
2142                .map_err(VectorError::Other)?;
2143        }
2144
2145        // The publish straddled a multi-second await; a swap must not strip the
2146        // message from the swapped-in account's STATE + DB (message_id is global).
2147        if !session.is_valid() {
2148            return Ok(());
2149        }
2150        let removed_chat = {
2151            let mut st = state::STATE.lock().await;
2152            st.remove_message(message_id).map(|(cid, _)| cid)
2153        };
2154        let _ = crate::db::events::delete_event(message_id).await;
2155        traits::emit_event_json("message_removed", serde_json::json!({
2156            "id": message_id, "chat_id": removed_chat.as_deref().unwrap_or(channel_id), "reason": "hidden",
2157        }));
2158        Ok(())
2159    }
2160
2161    /// Shared community control-event publish (reaction / edit / delete tombstone): build the
2162    /// inner-typed envelope, sign, send over the community transport, then locally echo + persist + emit.
2163    async fn publish_community_control(
2164        &self,
2165        channel_id: &str,
2166        kind: u16,
2167        content: &str,
2168        target: &str,
2169        emoji_tags: &[crate::types::EmojiTag],
2170    ) -> Result<()> {
2171        use crate::community::{envelope, inbound, service, transport::LiveTransport};
2172        let (community, channel) = self.resolve_channel(channel_id)?;
2173        let author_pk = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
2174        let ms = std::time::SystemTime::now()
2175            .duration_since(std::time::UNIX_EPOCH)
2176            .map(|d| d.as_millis() as u64)
2177            .unwrap_or(0);
2178        let unsigned = envelope::build_inner_typed(
2179            author_pk, &channel.id, channel.epoch, kind, content, ms, Some(target), emoji_tags,
2180        );
2181        let _client = state::nostr_client().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
2182        let signer = crate::signer::active_signer().map_err(|e| VectorError::Other(format!("Signer unavailable: {e}")))?;
2183        let inner = unsigned.finalize_async(&signer).await.map_err(|e| VectorError::Other(format!("sign: {e}")))?;
2184        let session = state::SessionGuard::capture();
2185        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2186        let outer = service::send_signed_message(&transport, &community, &channel, &inner)
2187            .await.map_err(VectorError::Other)?;
2188        // Local echo + persist + emit (relay echo dedups on inner id). A swap during the
2189        // publish must not echo account A's control event into account B.
2190        if !session.is_valid() {
2191            return Ok(());
2192        }
2193        let outcome = {
2194            let mut st = state::STATE.lock().await;
2195            inbound::process_incoming(&mut st, &outer, &channel, &author_pk)
2196        };
2197        if let Some(inbound::IncomingEvent::Updated { target_id, mut message, edit_event }) = outcome {
2198            if let Some(ev) = edit_event {
2199                let mut ev = (*ev).clone();
2200                if let Ok(cid) = crate::db::id_cache::get_chat_id_by_identifier(channel_id) { ev.chat_id = cid; }
2201                let _ = crate::db::events::save_event(&ev).await;
2202            } else {
2203                let _ = crate::db::events::save_message(channel_id, &message).await;
2204            }
2205            traits::emit_message_update(channel_id, &target_id, &mut message).await;
2206        }
2207        Ok(())
2208    }
2209
2210    /// Catch a Community channel up from relays. v1: fetch + ingest the latest page of messages,
2211    /// reactions, edits, and deletes, returning how many were brand-new. v2: consensus catch-up
2212    /// only (rekeys + control refold) — chat history delivers over the live handler bridge, so the
2213    /// count is always 0. Returns `(new_message_count, warnings)`; `warnings` are NON-FATAL errors
2214    /// hit during the sync (catch-up, control fold, read-cut resume) — surfaced rather than
2215    /// swallowed so a headless caller is never blind to "the sync ran but a re-founding couldn't
2216    /// be resumed."
2217    pub async fn sync_community_channel(&self, channel_id: &str, limit: usize) -> Result<(usize, Vec<String>)> {
2218        use crate::community::{inbound, send, service, transport::LiveTransport};
2219        let my_pk = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?;
2220        // v2: consensus catch-up (rekeys then control refold) + chat backfill. With a
2221        // running listen() the coalescing worker owns the follow (never run inline beside
2222        // it — two concurrent follows can whole-row clobber); headless, walk it inline.
2223        // The chat page is fetched + persisted either way, so get_messages backfills.
2224        if let Some(id) = self.v2_community_for_channel(channel_id)? {
2225            let warnings = if community::v2::realtime::follow_worker_running() {
2226                community::v2::realtime::enqueue_follow(&id);
2227                Vec::new()
2228            } else {
2229                Self::v2_inline_follow(&id).await
2230            };
2231            // Deepest catch-up walk: pages × page-size bounds one reconnect's fetch.
2232            // Chat plane = fetch ASAP. NOTE: fetch_plane does not consult the
2233            // evidence tier yet (#370) — until it does, the transport-seconds
2234            // bound is the effective limit; the declared Fast records intent.
2235            let new = Self::v2_backfill_channel(
2236                &id, channel_id, limit, 8, None,
2237                crate::community::transport::Evidence::Fast, 12,
2238            ).await;
2239            return Ok((new, warnings));
2240        }
2241        let (community, _) = self.resolve_channel(channel_id)?;
2242        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2243        let mut warnings: Vec<String> = Vec::new();
2244
2245        // FIRST: walk any base (server-root) rotation — a privatize / private-ban rekey advances the
2246        // epoch and re-anchors the control plane under the NEW root, so we must follow it BEFORE reading
2247        // control/messages or we'd look at stale-epoch pseudonyms and silently fall off. No-op (one cheap
2248        // probe) when there's been no rotation. Re-resolve after: the base epoch + root may have advanced.
2249        // An AUTHORIZED base rotation that excluded us (private ban / read-cut) is a removal: erase local
2250        // community data, exactly like an observed banlist/kick. This is the catch-all for a cut member who
2251        // can no longer decrypt the new control plane to read the banlist the normal way (`am_i_banned`).
2252        match service::catch_up_server_root(&transport, &community).await {
2253            Ok(c) if c.removed => {
2254                // ban-rekey exclusion is a self-removal → retain the held epoch keys for later self-scrub.
2255                let _ = crate::db::community::delete_community_retain_keys(&community.id.to_hex());
2256                return Ok((0, warnings));
2257            }
2258            Ok(_) => {}
2259            Err(e) => warnings.push(format!("base catch-up failed: {e}")),
2260        }
2261        let (community, _) = self.resolve_channel(channel_id)?;
2262
2263        // Headless clients have no realtime control-plane subscription, so fold the latest control editions
2264        // here (the desktop does the same on its own latest-page sync). Banlist FIRST: a ban that landed on
2265        // us self-removes like a kick (drop keys + local data, no rejoin). Then roles, the per-creator invite
2266        // links (Public/Private mode), and metadata (name/description/icon/channel-name) — so a rename, role,
2267        // ban, or mode change reaches this member on sync, not just in a realtime client.
2268        if let Err(e) = service::fetch_and_apply_control(&transport, &community).await {
2269            warnings.push(format!("control fold failed: {e}"));
2270        }
2271        if service::am_i_banned(&community) {
2272            // ban self-removal → retain the held epoch keys for later self-scrub.
2273            let _ = crate::db::community::delete_community_retain_keys(&community.id.to_hex());
2274            return Ok((0, warnings));
2275        }
2276        // Walk any CHANNEL rekey so we hold the current channel key before paging it, then re-resolve so the
2277        // batch below carries the fresh channel epoch/key + the freshly-folded banned set + metadata.
2278        let (community, channel) = self.resolve_channel(channel_id)?;
2279        if let Err(e) = service::catch_up_channel_rekeys(&transport, &community, &channel.id).await {
2280            warnings.push(format!("channel catch-up failed: {e}"));
2281        }
2282        // Resume any interrupted re-founding (a privatize/ban whose rotation aborted mid-way — e.g. a
2283        // transient relay miss on the re-anchor). The GUI's sync did this; the agent's path did NOT, so an
2284        // interrupted re-founding stayed `read_cut_pending` forever (channel frozen). Best-effort + surfaced.
2285        let (community, _) = self.resolve_channel(channel_id)?;
2286        if let Err(e) = service::retry_pending_read_cut(&transport, &community).await {
2287            warnings.push(format!("read-cut resume failed: {e}"));
2288        }
2289        let (community, channel) = self.resolve_channel(channel_id)?;
2290
2291        // Guard straddles the fetch: the persist walk below writes this account's DB.
2292        let session = state::SessionGuard::capture();
2293        let events = send::fetch_channel_page(&transport, &community, &channel, None, None, limit.max(1))
2294            .await
2295            .map_err(VectorError::Other)?;
2296        let outcomes = {
2297            let mut st = state::STATE.lock().await;
2298            inbound::process_channel_batch(&mut st, &events, &channel, &my_pk)
2299        };
2300        let mut new = 0usize;
2301        // Message saves COLLECT into one batched transaction; deletes are flush barriers
2302        // (see flush_message_batch — a save committing after a delete it preceded on the
2303        // wire would resurrect the deleted row).
2304        let mut pending: Vec<&crate::types::Message> = Vec::new();
2305        for o in &outcomes {
2306            // Every arm below writes this account's DB — a swap can land between them.
2307            if !session.is_valid() {
2308                pending.clear();
2309                break;
2310            }
2311            match o {
2312                inbound::IncomingEvent::NewMessage(m) => {
2313                    pending.push(m);
2314                    new += 1;
2315                }
2316                inbound::IncomingEvent::Updated { message, .. } => {
2317                    pending.push(message);
2318                }
2319                inbound::IncomingEvent::Removed { target_id } => {
2320                    crate::db::events::flush_message_batch(channel_id, &mut pending, &session).await;
2321                    let _ = crate::db::events::delete_event(target_id).await;
2322                }
2323                inbound::IncomingEvent::ReactionRemoved { reaction_id, .. } => {
2324                    // save_message is additive, so a revoked reaction's kind-7 row must be
2325                    // dropped explicitly or it resurrects on reload.
2326                    crate::db::events::flush_message_batch(channel_id, &mut pending, &session).await;
2327                    let _ = crate::db::events::delete_event(reaction_id).await;
2328                }
2329                inbound::IncomingEvent::Presence { npub, joined, event_id, created_at, invited_by, invited_label } => {
2330                    let et = if *joined {
2331                        crate::stored_event::SystemEventType::MemberJoined
2332                    } else {
2333                        crate::stored_event::SystemEventType::MemberLeft
2334                    };
2335                    // attribution persisted in the note: "invited_by[|label]".
2336                    let note = invited_by.as_ref().map(|by| match invited_label {
2337                        Some(l) if !l.is_empty() => format!("{by}|{l}"),
2338                        _ => by.clone(),
2339                    });
2340                    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;
2341                }
2342                inbound::IncomingEvent::WebxdcPeer { npub, topic_id, node_addr, event_id, created_at } => {
2343                    // Persist only (DM-parity row) — the miniapp layer bootstraps from the DB at
2344                    // game-open. Live gossip-feed pokes are the realtime subscription's job.
2345                    community::service::persist_webxdc_signal(
2346                        channel_id, npub, topic_id, node_addr.as_deref(), event_id, *created_at,
2347                    ).await;
2348                }
2349                inbound::IncomingEvent::Kicked { community_id }
2350                | inbound::IncomingEvent::SelfLeft { community_id } => {
2351                    // self-removal (kick of me, or a leave I/another device authored): drop the
2352                    // community's local state but RETAIN the held epoch keys (later self-scrub). The core-level
2353                    // half of leaving; a client shell layers on subscription-refresh + chat-row teardown + UI.
2354                    // Stop the batch — the community is gone, so later same-batch writes would orphan rows.
2355                    crate::db::events::flush_message_batch(channel_id, &mut pending, &session).await;
2356                    let _ = crate::db::community::delete_community_retain_keys(community_id);
2357                    break;
2358                }
2359                inbound::IncomingEvent::Typing { .. } => {
2360                    // Realtime-only ephemeral signal; never fetched in a sync batch. No-op.
2361                }
2362            }
2363        }
2364        crate::db::events::flush_message_batch(channel_id, &mut pending, &session).await;
2365        Ok((new, warnings))
2366    }
2367
2368    /// The composer's `/` picker snapshot for `chat_id`, answered INSTANTLY
2369    /// from local state: the chat's bot-flagged members (kind-0 `bot: true` —
2370    /// the SDK sets it on every bot it builds) and their last-known manifests
2371    /// from the persistent store. When the last refresh is older than a minute
2372    /// (or the bot set changed), ONE background REQ re-fetches every bot's
2373    /// manifest together (5s unification window), persists newer editions, and
2374    /// emits `chat_commands_updated` — the UI swaps the list in when it lands.
2375    /// Works for BOTH community protocols (an invocation is plain content; only
2376    /// the optional routing tag is v2-only) and DMs. The manifest REQ always
2377    /// includes the discovery indexers beside the chat's own relays, so an
2378    /// unreachable or stranger-dropping community relay can't blind the picker.
2379    pub async fn get_chat_commands(&self, chat_id: &str) -> crate::bot_interface::ChatCommandsSnapshot {
2380        use crate::bot_interface::{self, ChatCommandsSnapshot};
2381        use nostr_sdk::prelude::ToBech32;
2382
2383        let mut bots: Vec<nostr_sdk::prelude::PublicKey> = Vec::new();
2384        let mut relays: Vec<String> = Vec::new();
2385        let community_hex = crate::db::community::community_id_for_channel(chat_id).ok().flatten();
2386        if let Some(cid_hex) = community_hex {
2387            let mut members: Vec<nostr_sdk::prelude::PublicKey> = Vec::new();
2388            if let Ok(Some(community)) = Self::load_v2_if_v2(&cid_hex) {
2389                members = community::v2::service::stored_memberlist(&community).unwrap_or_default();
2390                relays = community.relays.clone();
2391            } else {
2392                let id = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid_hex));
2393                let Ok(Some(community)) = crate::db::community::load_community(&id) else {
2394                    return ChatCommandsSnapshot { bots: 0, commands: Vec::new(), fresh: true };
2395                };
2396                relays = community.relays.clone();
2397                for (npub, _) in crate::db::community::community_member_activity(&cid_hex).unwrap_or_default() {
2398                    if let Ok(pk) = nostr_sdk::prelude::PublicKey::parse(&npub) {
2399                        members.push(pk);
2400                    }
2401                }
2402            }
2403            let state = crate::state::STATE.lock().await;
2404            for pk in members {
2405                let Ok(npub) = pk.to_bech32();
2406                if state.get_profile(&npub).map(|p| p.flags.is_bot()).unwrap_or(false) {
2407                    bots.push(pk);
2408                }
2409            }
2410        } else if chat_id.starts_with("npub1") {
2411            if let Ok(pk) = nostr_sdk::prelude::PublicKey::parse(chat_id) {
2412                let is_bot = {
2413                    let state = crate::state::STATE.lock().await;
2414                    state.get_profile(chat_id).map(|p| p.flags.is_bot()).unwrap_or(false)
2415                };
2416                if is_bot {
2417                    bots.push(pk);
2418                    // The counterpart published its manifest to its own login
2419                    // relays/indexers — our connected pool is the read set.
2420                    if let Some(client) = crate::state::nostr_client() {
2421                        relays = client.relays().await.keys().map(|u| u.to_string()).collect();
2422                    }
2423                }
2424            }
2425        }
2426
2427        if bots.is_empty() {
2428            return ChatCommandsSnapshot { bots: 0, commands: Vec::new(), fresh: true };
2429        }
2430        // The chat's own relays PLUS the discovery indexers, one REQ across the
2431        // union — a room whose relays refuse kind 10304 still resolves.
2432        relays.extend(bot_interface::DISCOVERY_RELAYS.iter().map(|s| s.to_string()));
2433        relays.sort();
2434        relays.dedup();
2435        // Deterministic order: the freshness check compares the exact bot set,
2436        // and picker sections stay stable across refreshes.
2437        bots.sort_by_key(|p| p.to_hex());
2438        let bot_hexes: Vec<String> = bots.iter().map(|p| p.to_hex()).collect();
2439        let commands = bot_interface::assemble_from_store(&bot_hexes);
2440        let fresh = bot_interface::commands_fresh(chat_id, &bot_hexes);
2441        if !fresh {
2442            bot_interface::spawn_commands_refresh(chat_id.to_string(), bots.clone(), relays);
2443        }
2444        ChatCommandsSnapshot { bots: bots.len(), commands, fresh }
2445    }
2446
2447    /// Observed members of a Community (best-effort: those who've posted or announced a join,
2448    /// minus anyone who's left or is banned). v1 entries are `{npub, last_active}`; a v2 entry
2449    /// is `{npub}` (the Complete Memberlist carries no activity time). Best-effort throughout:
2450    /// a transport failure yields an empty list, never an error.
2451    pub async fn get_community_members(&self, community_id: &str) -> Vec<serde_json::Value> {
2452        use nostr_sdk::prelude::ToBech32;
2453        // v2: the Complete Memberlist from LOCAL state (persisted guestbook +
2454        // observed authors + roster grantees − banlist). The store is seeded
2455        // post-join and cursor-caught-up by the follow worker (boot/reconnect) +
2456        // live ingest; a cold store (a hold predating the store) seeds in the
2457        // background and refreshes the UI when it lands.
2458        match Self::load_v2_if_v2(community_id) {
2459            Ok(Some(community)) => {
2460                let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
2461                let (_, cursor) = crate::db::community::get_guestbook(&cid_hex).unwrap_or_default();
2462                if cursor == 0 {
2463                    if crate::community::v2::realtime::follow_worker_running() {
2464                        crate::community::v2::realtime::enqueue_follow(community.id());
2465                    } else {
2466                        let session = state::SessionGuard::capture();
2467                        let c2 = community.clone();
2468                        tokio::spawn(async move {
2469                            if !session.is_valid() {
2470                                return;
2471                            }
2472                            let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(20));
2473                            if matches!(crate::community::v2::service::sync_guestbook(&transport, &c2, &session).await, Ok(fresh) if !fresh.is_empty()) {
2474                                emit_event("community_refreshed", &serde_json::json!({ "community_id": cid_hex }));
2475                            }
2476                        });
2477                    }
2478                }
2479                return crate::community::v2::service::stored_memberlist(&community)
2480                    .unwrap_or_default()
2481                    .into_iter()
2482                    .filter_map(|pk| pk.to_bech32().ok())
2483                    .map(|npub| serde_json::json!({ "npub": npub }))
2484                    .collect();
2485            }
2486            Ok(None) => {} // genuinely v1 / unknown — fall through.
2487            // Can't determine the protocol: best-effort empty, never a v1 guess.
2488            Err(_) => return Vec::new(),
2489        }
2490        crate::db::community::community_member_activity(community_id)
2491            .unwrap_or_default()
2492            .into_iter()
2493            .map(|(npub, last_active)| serde_json::json!({ "npub": npub, "last_active": last_active }))
2494            .collect()
2495    }
2496
2497    /// One synchronous v2 follow pass — rekeys first (a base adopt moves the
2498    /// control address), then a control refold on the FRESH state, the same order
2499    /// the live follow worker runs. Returns non-fatal warnings.
2500    async fn v2_inline_follow(id: &crate::community::CommunityId) -> Vec<String> {
2501        use crate::community::transport::LiveTransport;
2502        let session = state::SessionGuard::capture();
2503        // Serialize with the live follow worker: `follow_worker_running` is
2504        // check-then-act, so a worker can spawn right after a caller saw `false` —
2505        // this shared per-community lock is what actually prevents two follows of
2506        // one community interleaving their whole-row saves.
2507        let lock = crate::community::v2::realtime::follow_lock(id);
2508        let _guard = lock.lock().await;
2509        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2510        let mut warnings: Vec<String> = Vec::new();
2511        let Ok(Some(community)) = crate::db::community::load_community_v2(id) else {
2512            warnings.push("v2 community not found".to_string());
2513            return warnings;
2514        };
2515        let cid_hex = crate::simd::hex::bytes_to_hex_32(&id.0);
2516        match crate::community::v2::service::follow_rekeys(&transport, &community, &session).await {
2517            // A tombstone surfaced during catch-up — sealed read-only; stop here.
2518            Ok(f) if f.dissolved => return warnings,
2519            Ok(f) if f.self_removed => {
2520                // An authorized rotation that excluded us IS a removal — but the
2521                // follow straddled awaits, so never delete from a swapped-in DB.
2522                if session.is_valid() {
2523                    let _ = crate::db::community::delete_community(&cid_hex);
2524                }
2525                return warnings;
2526            }
2527            Ok(_) => {}
2528            Err(e) => warnings.push(format!("v2 rekey follow failed: {e}")),
2529        }
2530        if let Ok(Some(fresh)) = crate::db::community::load_community_v2(id) {
2531            match crate::community::v2::service::follow_control(&transport, &fresh, &session).await {
2532                // A control change can reveal rekey work that predates it (a
2533                // just-announced private channel's key crate already sits on its
2534                // rekey plane), so walk the rekeys once more on the fresh state.
2535                Ok(Some(changed)) => {
2536                    if let Err(e) = crate::community::v2::service::follow_rekeys(&transport, &changed, &session).await {
2537                        warnings.push(format!("v2 rekey follow failed: {e}"));
2538                    }
2539                }
2540                Ok(None) => {}
2541                Err(e) => warnings.push(format!("v2 control follow failed: {e}")),
2542            }
2543        }
2544        // Banned by the freshly-folded banlist: a removal just like the rotation
2545        // exclusion above, and it arrives FIRST (CORD-04 §6 orders the Banlist edition
2546        // before the Refounding), so keying removal solely off the rotation leaves a
2547        // banned headless client running against a community that already dropped it.
2548        if let Some(me) = crate::my_public_key() {
2549            if crate::db::community::is_author_banned(&cid_hex, &me) && session.is_valid() {
2550                let _ = crate::db::community::delete_community(&cid_hex);
2551            }
2552        }
2553        warnings
2554    }
2555
2556    /// Fetch a v2 channel's recent chat history and PERSIST it into the shared events
2557    /// tables (the same store v1 uses), so `get_messages`/`get_new_messages` backfill for
2558    /// v2 exactly like v1. PAGES backwards until it reaches messages it already holds
2559    /// (bounded), so a reconnecting bot that slept through more than one page of traffic
2560    /// still catches the whole gap instead of only the newest `limit`. Reuses the v2
2561    /// inbound bridge (dedup + STATE aggregate) + the v1 save path. Returns the count of
2562    /// brand-new messages applied. Best-effort: a fetch failure is 0.
2563    /// Reconnect/boot catch-up for one v2 channel: fetches the newest pages
2564    /// and PAGES backwards until it reaches messages it already holds, then
2565    /// ingests through the shared pipeline. The boot volley fetches its own
2566    /// batches and shares only [`Self::v2_ingest_chat_page`].
2567    async fn v2_backfill_channel(
2568        id: &crate::community::CommunityId,
2569        channel_id: &str,
2570        limit: usize,
2571        max_pages: usize,
2572        since: Option<u64>,
2573        evidence: crate::community::transport::Evidence,
2574        transport_secs: u64,
2575    ) -> usize {
2576        use crate::community::v2::inbound::{apply_chat_to_state, ChatPersist};
2577        // Guard straddles the fetch: a swap mid-fetch must not persist account A's chat
2578        // into account B's STATE/DB (the message ids are global).
2579        let session = state::SessionGuard::capture();
2580        let Some(my_pk) = state::my_public_key() else { return 0 };
2581        // CORD-02 §9: a dissolved community honors no NEW events — old history reads
2582        // through the explicit paths, but a catch-up sweep must not ingest anything
2583        // authored into the grave.
2584        if crate::db::community::get_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&id.0)).unwrap_or(false) {
2585            return 0;
2586        }
2587        let Ok(Some(community)) = crate::db::community::load_community_v2(id) else { return 0 };
2588        let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
2589        let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(transport_secs));
2590        let Ok(page) = crate::community::v2::service::fetch_channel_history(
2591            &transport,
2592            &community,
2593            &ch,
2594            limit.max(50),
2595            max_pages,
2596            since,
2597            evidence,
2598            // Keep paging while a page still contains a MESSAGE we don't hold; a page
2599            // whose messages are all known means we've reached our own history. Only
2600            // message kinds get their own rows (reactions/edits fold into their
2601            // targets), so a page with no messages is undecidable — keep paging.
2602            |page| {
2603                let mut saw_message = false;
2604                for f in page {
2605                    if matches!(&f.event, crate::community::v2::chat::ChatEvent::Message { .. }) {
2606                        saw_message = true;
2607                        if !crate::db::events::event_exists(&f.event.opened().rumor_id.to_hex()).unwrap_or(false) {
2608                            return true;
2609                        }
2610                    }
2611                }
2612                !saw_message
2613            },
2614        )
2615        .await
2616        else {
2617            return 0;
2618        };
2619        Self::v2_ingest_chat_page(channel_id, my_pk, session, page).await
2620    }
2621
2622    /// Ingest a fetched chat page: STATE apply, batched persist with delete
2623    /// barriers, then UI surfacing — shared by the reconnect backfill and the
2624    /// boot volley's batched paint path.
2625    pub(crate) async fn v2_ingest_chat_page(
2626        channel_id: &str,
2627        my_pk: nostr_sdk::prelude::PublicKey,
2628        session: crate::state::SessionGuard,
2629        page: Vec<crate::community::v2::service::FetchedEvent>,
2630    ) -> usize {
2631        use crate::community::v2::inbound::{apply_chat_to_state, ChatPersist};
2632        let mut new = 0usize;
2633        // Pass 1 — apply to STATE (per-item lock) and COLLECT outcomes in wire order.
2634        let mut outcomes: Vec<ChatPersist> = Vec::with_capacity(page.len());
2635        for f in &page {
2636            // Re-check every iteration — STATE mutates per item, and a swap can land between them.
2637            if !session.is_valid() {
2638                break;
2639            }
2640            // A backfilled WebXDC peer ad persists through the shared 30078 row
2641            // (recency-gated at read) so a reopening lobby lists peers who
2642            // advertised while this device was closed — v1 sync parity. Own
2643            // echoes drop; the ad is not a chat row.
2644            if let crate::community::v2::chat::ChatEvent::Webxdc { opened } = &f.event {
2645                if opened.author != my_pk {
2646                    if let Some((topic, addr)) = crate::webxdc::parse_peer_signal(&opened.rumor.content) {
2647                        let Ok(npub) = ToBech32::to_bech32(&opened.author);
2648                        crate::community::service::persist_webxdc_signal(
2649                            channel_id,
2650                            &npub,
2651                            &topic,
2652                            addr.as_deref(),
2653                            &opened.rumor_id.to_hex(),
2654                            opened.at_ms / 1000,
2655                        )
2656                        .await;
2657                    }
2658                }
2659                continue;
2660            }
2661            let outcome = {
2662                let mut st = state::STATE.lock().await;
2663                apply_chat_to_state(&mut st, &f.event, channel_id, &my_pk)
2664            };
2665            if let Some(outcome) = outcome {
2666                if matches!(outcome, ChatPersist::New(_)) {
2667                    new += 1;
2668                }
2669                outcomes.push(outcome);
2670            }
2671        }
2672        // Pass 2 — persist: message saves COLLECT into batched transactions; deletes are
2673        // flush barriers (a save committing after a delete it preceded on the wire would
2674        // resurrect the deleted row). One tx per page in the common no-delete case.
2675        let mut pending: Vec<&crate::types::Message> = Vec::new();
2676        for outcome in &outcomes {
2677            if !session.is_valid() {
2678                pending.clear();
2679                break;
2680            }
2681            match outcome {
2682                ChatPersist::New(m) => pending.push(m),
2683                ChatPersist::Updated { message, edit_event } => match edit_event {
2684                    Some(ev) => {
2685                        let mut ev = (**ev).clone();
2686                        // get-or-CREATE: a lookup-only id would leave a fresh channel's edit at
2687                        // chat_id 0 (orphaned, dropped on the reload fold).
2688                        if let Ok(cid) = crate::db::id_cache::get_or_create_chat_id(channel_id) {
2689                            ev.chat_id = cid;
2690                        }
2691                        let _ = crate::db::events::save_event(&ev).await;
2692                    }
2693                    None => pending.push(message),
2694                },
2695                ChatPersist::Removed(target_id) => {
2696                    crate::db::events::flush_message_batch(channel_id, &mut pending, &session).await;
2697                    let _ = crate::db::events::delete_event(target_id).await;
2698                }
2699                ChatPersist::ReactionRemoved { reaction_id, message } => {
2700                    crate::db::events::flush_message_batch(channel_id, &mut pending, &session).await;
2701                    let _ = crate::db::events::delete_event(reaction_id).await;
2702                    pending.push(message);
2703                }
2704            }
2705        }
2706        crate::db::events::flush_message_batch(channel_id, &mut pending, &session).await;
2707        // Pass 3 — surface to the live UI, mirroring v1's sweep + the live dispatch handler:
2708        // a silent DB-only backfill left the chat-list preview, unread badge, and sort order
2709        // stale until the channel was opened. Raw emits (no notification ping) — a boot
2710        // catch-up must not fire an OS ping per message. Headless consumers register no
2711        // emitter, so these are a no-op there. After the persists so nothing surfaces unsaved.
2712        if session.is_valid() {
2713            for outcome in &outcomes {
2714                match outcome {
2715                    ChatPersist::New(msg) => crate::traits::emit_event(
2716                        "message_new",
2717                        &serde_json::json!({ "message": msg, "chat_id": channel_id }),
2718                    ),
2719                    ChatPersist::Updated { message, .. }
2720                    | ChatPersist::ReactionRemoved { message, .. } => {
2721                        let mut message = message.clone();
2722                        let target_id = message.id.clone();
2723                        crate::traits::emit_message_update(channel_id, &target_id, &mut message).await;
2724                    }
2725                    ChatPersist::Removed(target_id) => crate::traits::emit_event(
2726                        "message_removed",
2727                        &serde_json::json!({ "id": target_id, "chat_id": channel_id, "reason": "deleted" }),
2728                    ),
2729                }
2730            }
2731        }
2732        new
2733    }
2734
2735    /// The held v2 community when `community_id` names one; `Ok(None)` for v1 (or
2736    /// unknown). A DB read error PROPAGATES (fail-closed) instead of falling open
2737    /// to the v1 route on a transient failure.
2738    fn load_v2_if_v2(community_id: &str) -> Result<Option<crate::community::v2::community::CommunityV2>> {
2739        if community_id.len() != 64 {
2740            return Ok(None);
2741        }
2742        let cid = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
2743        match crate::db::community::community_protocol(&cid).map_err(VectorError::Other)? {
2744            Some(crate::community::ConcordProtocol::V2) => crate::db::community::load_community_v2(&cid).map_err(VectorError::Other),
2745            _ => Ok(None),
2746        }
2747    }
2748
2749    // ── Community admin actions ── role-gated; vector-core re-checks authority on every action and peers
2750    // re-verify against the owner-rooted roster, so these can't forge standing. A bunker account can't ban
2751    // in a private community (the rekey needs a raw local key).
2752
2753    fn load_community_hex(community_id: &str) -> Result<crate::community::Community> {
2754        use crate::community::CommunityId;
2755        if community_id.len() != 64 {
2756            return Err(VectorError::Other("malformed community id".into()));
2757        }
2758        crate::db::community::load_community(&CommunityId(crate::simd::hex::hex_to_bytes_32(community_id)))
2759            .map_err(VectorError::Other)?
2760            .ok_or_else(|| VectorError::Other("community not found".into()))
2761    }
2762
2763    fn admin_role_id_of(community_id: &str) -> Result<String> {
2764        let roles = crate::db::community::get_community_roles(community_id).map_err(VectorError::Other)?;
2765        roles.roles.iter()
2766            .find(|r| matches!(r.scope, crate::community::roles::RoleScope::Server)
2767                && r.permissions.contains(crate::community::roles::Permissions::ADMIN_ALL))
2768            .map(|r| r.role_id.clone())
2769            .ok_or_else(|| VectorError::Other("admin role not found (roster not synced?)".into()))
2770    }
2771
2772    /// My effective management capabilities in a community (role engine — owner is just position 0). Use to
2773    /// confirm a promotion/demotion landed. A local read: the roster is folded + persisted by the passive
2774    /// sync (v1) / control follow (v2), never fetched here.
2775    pub fn community_capabilities(&self, community_id: &str) -> Result<serde_json::Value> {
2776        use crate::community::service;
2777        if let Some(v2) = Self::load_v2_if_v2(community_id)? {
2778            use crate::community::roles::Permissions;
2779            let me = state::my_public_key().ok_or_else(|| VectorError::Other("Not logged in".into()))?.to_hex();
2780            let owner_hex = v2.owner().map_err(VectorError::Other)?.to_hex();
2781            let roster = crate::db::community::get_community_roles(community_id).map_err(VectorError::Other)?;
2782            // A banned member holds no standing (CORD-04 §4), even if a since-skipped
2783            // roster persist still lists their grant — the banlist advances on its own gate.
2784            let banned = crate::db::community::get_community_banlist(community_id).unwrap_or_default();
2785            if banned.contains(&me) && me != owner_hex {
2786                return Ok(serde_json::json!({
2787                    "manage_metadata": false, "manage_channels": false, "create_invite": false, "kick": false,
2788                    "ban": false, "manage_messages": false, "manage_roles": false, "manage_admin_role": false,
2789                }));
2790            }
2791            let has = |p: u64| roster.is_authorized(&me, Some(&owner_hex), p);
2792            return Ok(serde_json::json!({
2793                "manage_metadata": has(Permissions::MANAGE_METADATA), "manage_channels": has(Permissions::MANAGE_CHANNELS),
2794                "create_invite": has(Permissions::CREATE_INVITE), "kick": has(Permissions::KICK), "ban": has(Permissions::BAN),
2795                "manage_messages": has(Permissions::MANAGE_MESSAGES), "manage_roles": has(Permissions::MANAGE_ROLES),
2796                // Only the owner (position 0) strictly outranks the position-1 Admin role.
2797                "manage_admin_role": me == owner_hex,
2798            }));
2799        }
2800        let community = Self::load_community_hex(community_id)?;
2801        let caps = service::caller_capabilities(&community);
2802        let manage_admin_role = Self::admin_role_id_of(community_id).ok()
2803            .map(|rid| service::caller_can_manage_role_id(&community, &rid))
2804            .unwrap_or(false);
2805        Ok(serde_json::json!({
2806            "manage_metadata": caps.manage_metadata, "manage_channels": caps.manage_channels,
2807            "create_invite": caps.create_invite, "kick": caps.kick, "ban": caps.ban,
2808            "manage_messages": caps.manage_messages, "manage_roles": caps.manage_roles,
2809            "manage_admin_role": manage_admin_role,
2810        }))
2811    }
2812
2813    /// The community's owner npub + the admin npubs (role overview). A local read,
2814    /// like [`Self::community_capabilities`].
2815    pub fn community_roles(&self, community_id: &str) -> Result<serde_json::Value> {
2816        use nostr_sdk::prelude::{PublicKey, ToBech32};
2817        if let Some(v2) = Self::load_v2_if_v2(community_id)? {
2818            let owner = v2.owner().map_err(VectorError::Other)?;
2819            let roster = crate::db::community::get_community_roles(community_id).map_err(VectorError::Other)?;
2820            // Exclude banned members from the admin list (a banned npub vanishes, §4).
2821            let banned = crate::db::community::get_community_banlist(community_id).unwrap_or_default();
2822            let admins: Vec<String> = roster.grants.iter()
2823                .filter(|g| roster.is_admin(&g.member) && !banned.contains(&g.member))
2824                .filter_map(|g| PublicKey::from_hex(&g.member).ok().and_then(|pk| pk.to_bech32().ok()))
2825                .collect();
2826            return Ok(serde_json::json!({ "owner": owner.to_bech32().ok(), "admins": admins }));
2827        }
2828        let community = Self::load_community_hex(community_id)?;
2829        let owner = community.owner_attestation.as_ref()
2830            .and_then(|att| crate::community::owner::verify_owner_attestation(att, &community.id.to_hex()))
2831            .and_then(|pk| ToBech32::to_bech32(&pk).ok());
2832        let roles = crate::db::community::get_community_roles(community_id).map_err(VectorError::Other)?;
2833        let admins: Vec<String> = roles.grants.iter().filter(|g| roles.is_admin(&g.member))
2834            .filter_map(|g| PublicKey::from_hex(&g.member).ok().and_then(|pk| pk.to_bech32().ok()))
2835            .collect();
2836        Ok(serde_json::json!({ "owner": owner, "admins": admins }))
2837    }
2838
2839    /// Fold the v2 control plane back in right after publishing an authority change, so the
2840    /// LOCAL roster/banlist — which is what every read is served from (crowns, in-chat tags,
2841    /// capabilities, moderation gates) — is current by the time the call returns. `publish`
2842    /// only returns once a relay ACKed, so this refetch sees our own edition; the fold
2843    /// announces `community_refreshed` itself when the roster actually moved.
2844    ///
2845    /// Best-effort: the edition is already published, so a failed refold is a stale local
2846    /// cache the next follow repairs, never a failed action.
2847    async fn converge_v2_authority(
2848        transport: &crate::community::transport::LiveTransport,
2849        community_id: &str,
2850        session: &crate::state::SessionGuard,
2851    ) {
2852        if !session.is_valid() {
2853            return;
2854        }
2855        // Reload rather than reuse the caller's clone: the publish advanced edition floors,
2856        // and a rekey/refound may have moved the control address under us.
2857        if let Ok(Some(fresh)) = Self::load_v2_if_v2(community_id) {
2858            let _ = crate::community::v2::service::follow_control(transport, &fresh, session).await;
2859            // Membership is part of the view being converged: an unban must
2860            // re-fetch the Guestbook, because a Join that legally raced the ban
2861            // window may exist only on the relays — and our own just-published
2862            // edition doesn't echo back to trigger a follow.
2863            if let Ok(added) = crate::community::v2::service::sync_guestbook(transport, &fresh, session).await {
2864                if !added.is_empty() && session.is_valid() {
2865                    traits::emit_event_json(
2866                        "community_refreshed",
2867                        serde_json::json!({ "community_id": community_id }),
2868                    );
2869                }
2870            }
2871        }
2872    }
2873
2874    /// Grant a member the @admin role. Requires MANAGE_ROLES + outranking the role's position.
2875    pub async fn grant_admin(&self, community_id: &str, npub: &str) -> Result<()> {
2876        use crate::community::{service, transport::LiveTransport};
2877        let session = crate::state::SessionGuard::capture();
2878        let member = nostr_sdk::prelude::PublicKey::parse(npub).map_err(|_| VectorError::Other("invalid npub".into()))?;
2879        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2880        if let Some(v2) = Self::load_v2_if_v2(community_id)? {
2881            crate::community::v2::service::grant_admin(&transport, &v2, &member)
2882                .await
2883                .map_err(VectorError::Other)?;
2884            Self::converge_v2_authority(&transport, community_id, &session).await;
2885            return Ok(());
2886        }
2887        let community = Self::load_community_hex(community_id)?;
2888        let role_id = Self::admin_role_id_of(community_id)?;
2889        service::grant_role(&transport, &community, member, &role_id).await.map_err(VectorError::Other)
2890    }
2891
2892    /// Revoke a member's @admin role.
2893    pub async fn revoke_admin(&self, community_id: &str, npub: &str) -> Result<()> {
2894        use crate::community::{service, transport::LiveTransport};
2895        let session = crate::state::SessionGuard::capture();
2896        let member = nostr_sdk::prelude::PublicKey::parse(npub).map_err(|_| VectorError::Other("invalid npub".into()))?;
2897        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2898        if let Some(v2) = Self::load_v2_if_v2(community_id)? {
2899            crate::community::v2::service::revoke_admin(&transport, &v2, &member)
2900                .await
2901                .map_err(VectorError::Other)?;
2902            Self::converge_v2_authority(&transport, community_id, &session).await;
2903            return Ok(());
2904        }
2905        let community = Self::load_community_hex(community_id)?;
2906        let role_id = Self::admin_role_id_of(community_id)?;
2907        service::revoke_role(&transport, &community, member, &role_id).await.map_err(VectorError::Other)
2908    }
2909
2910    /// Cooperatively kick a member — they self-remove but can rejoin. Requires KICK + outrank.
2911    pub async fn kick_member(&self, community_id: &str, npub: &str) -> Result<()> {
2912        use crate::community::{service, transport::LiveTransport};
2913        let session = crate::state::SessionGuard::capture();
2914        let pk = nostr_sdk::prelude::PublicKey::parse(npub).map_err(|_| VectorError::Other("invalid npub".into()))?;
2915        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2916        if let Some(v2) = Self::load_v2_if_v2(community_id)? {
2917            crate::community::v2::service::kick_member(&transport, &v2, &pk)
2918                .await
2919                .map_err(VectorError::Other)?;
2920            // Catch the local Guestbook up on our own Kick, so the memberlist read
2921            // (which folds the STORE, not the network) drops them before this returns
2922            // instead of waiting on the relay echo. The control fold follows because a
2923            // Kick strips roles first (CORD-04 §6), which moves the roster too.
2924            if session.is_valid() {
2925                if let Ok(fresh) = crate::community::v2::service::sync_guestbook(&transport, &v2, &session).await {
2926                    if !fresh.is_empty() {
2927                        emit_event("community_refreshed", &serde_json::json!({ "community_id": community_id }));
2928                    }
2929                }
2930            }
2931            Self::converge_v2_authority(&transport, community_id, &session).await;
2932            return Ok(());
2933        }
2934        let community = Self::load_community_hex(community_id)?;
2935        let channel = community.channels.first().ok_or_else(|| VectorError::Other("community has no channel".into()))?;
2936        service::publish_kick(&transport, &community, channel, &pk.to_hex()).await.map(|_| ()).map_err(VectorError::Other)
2937    }
2938
2939    /// Ban (`true`) or unban (`false`) a member. Ban is terminal (no rejoin); in a private community it also
2940    /// fires the read-cut rekey (needs a local key). Requires BAN + outrank.
2941    pub async fn set_member_banned(&self, community_id: &str, npub: &str, banned: bool) -> Result<()> {
2942        use crate::community::{service, transport::LiveTransport, CommunityId};
2943        let session = crate::state::SessionGuard::capture();
2944        let pk = nostr_sdk::prelude::PublicKey::parse(npub).map_err(|_| VectorError::Other("invalid npub".into()))?;
2945        let hex = pk.to_hex();
2946        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2947        // Recompute the full list (latest-wins): drop any existing entry, then add if banning.
2948        let mut list = crate::db::community::get_community_banlist(community_id).map_err(VectorError::Other)?;
2949        list.retain(|h| h != &hex);
2950        if banned {
2951            list.push(hex);
2952        }
2953        // Dual-stack: a v2 Ban is the CORD-04 §6 three-removal composition, in order —
2954        // the Banlist edition first (instant silence), then the Grant strip (authority
2955        // removal), then the Refounding read-cut (cryptographic severance).
2956        if community_id.len() == 64 {
2957            let cid = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
2958            if let Some(Some(crate::community::ConcordProtocol::V2)) = crate::db::community::community_protocol(&cid).ok() {
2959                // Rotation barrier: a Ban's refound holds this lock for its whole
2960                // multi-publish rotation while the row still names the OLD root. An
2961                // unban/reban clicked in that window must WAIT and then load the
2962                // post-commit root — unlocked, it publishes the edit to the epoch
2963                // being buried, where no reader will ever fold it. Dropped before
2964                // `refound_community`, which re-acquires it (non-reentrant).
2965                let community = {
2966                    let lock = crate::community::v2::realtime::follow_lock(&cid);
2967                    let _rotation = lock.lock().await;
2968                    let community = crate::db::community::load_community_v2(&cid)
2969                        .map_err(VectorError::Other)?
2970                        .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
2971                    crate::community::v2::service::set_banlist(&transport, &community, &list).await.map_err(VectorError::Other)?;
2972                    if banned {
2973                        crate::community::v2::service::grant_roles(&transport, &community, &pk, vec![]).await.map_err(VectorError::Other)?;
2974                    }
2975                    community
2976                };
2977                if banned {
2978                    crate::community::v2::service::refound_community(&transport, &community, &[pk]).await.map_err(VectorError::Other)?;
2979                }
2980                Self::converge_v2_authority(&transport, community_id, &session).await;
2981                return Ok(());
2982            }
2983        }
2984        let community = Self::load_community_hex(community_id)?;
2985        service::publish_banlist(&transport, &community, &list).await.map_err(VectorError::Other)
2986    }
2987
2988    /// Owner dissolution / "Delete Community": publish the terminal GroupDissolved tombstone (and
2989    /// retire the owner's own invite links, no rekey), sealing the community permanently. Owner-only
2990    /// (re-verified cryptographically in `service::dissolve_community`); irreversible.
2991    pub async fn dissolve_community(&self, community_id: &str) -> Result<()> {
2992        use crate::community::{service, transport::LiveTransport, CommunityId};
2993        if community_id.len() != 64 {
2994            return Err(VectorError::Other("malformed community id".into()));
2995        }
2996        let cid = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
2997        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
2998        // Dual-stack: a v2 community dissolves at its own `community_id`-derived
2999        // dissolved plane (CORD-02 §9), NOT v1's control-plane roster edition.
3000        if let Some(Some(crate::community::ConcordProtocol::V2)) = crate::db::community::community_protocol(&cid).ok() {
3001            let community = crate::db::community::load_community_v2(&cid)
3002                .map_err(VectorError::Other)?
3003                .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
3004            return crate::community::v2::service::dissolve_community(&transport, &community)
3005                .await
3006                .map_err(VectorError::Other);
3007        }
3008        let community = Self::load_community_hex(community_id)?;
3009        service::dissolve_community(&transport, &community).await.map_err(VectorError::Other)
3010    }
3011
3012    /// Edit community metadata (name / description) as an authorized member (MANAGE_METADATA). `None` leaves
3013    /// a field unchanged; an empty description clears it.
3014    pub async fn edit_community_metadata(&self, community_id: &str, name: Option<&str>, description: Option<&str>) -> Result<()> {
3015        use crate::community::{service, transport::LiveTransport, CommunityId};
3016        let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
3017        // Dual-stack: a v2 metadata edit is an authorized vsk-0 control edition.
3018        // Overlay onto the FULL held document (`CommunityV2::metadata()`) — an
3019        // edition replaces the entity, so a bare name edit would otherwise wipe
3020        // the icon/banner for every member (CORD-02 §6).
3021        if community_id.len() == 64 {
3022            let cid = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
3023            if let Some(Some(crate::community::ConcordProtocol::V2)) = crate::db::community::community_protocol(&cid).ok() {
3024                let community = crate::db::community::load_community_v2(&cid)
3025                    .map_err(VectorError::Other)?
3026                    .ok_or_else(|| VectorError::Other("v2 community not found".into()))?;
3027                let mut meta = community.metadata();
3028                if let Some(n) = name {
3029                    meta.name = n.to_string();
3030                }
3031                if let Some(d) = description {
3032                    meta.description = if d.is_empty() { None } else { Some(d.to_string()) };
3033                }
3034                return crate::community::v2::service::edit_community_metadata(&transport, &community, &meta)
3035                    .await
3036                    .map_err(VectorError::Other);
3037            }
3038        }
3039        let mut community = Self::load_community_hex(community_id)?;
3040        if let Some(n) = name { community.name = n.to_string(); }
3041        if let Some(d) = description { community.description = if d.is_empty() { None } else { Some(d.to_string()) }; }
3042        service::republish_community_metadata(&transport, &community).await.map_err(VectorError::Other)
3043    }
3044
3045    /// Create a new channel in a v2 community. A PUBLIC channel derives from the
3046    /// community_root, so peers fold it in with nothing to distribute; a PRIVATE one
3047    /// mints an independent key at channel-epoch 1 and delivers it to every current
3048    /// member over the rekey plane (CORD-03 §2 / CORD-06). Requires MANAGE_CHANNELS.
3049    /// Returns the new channel id (hex).
3050    pub async fn create_community_channel(&self, community_id: &str, name: &str, private: bool) -> Result<String> {
3051        let v2 = Self::load_v2_if_v2(community_id)?
3052            .ok_or_else(|| VectorError::Other("channel creation is available on v2 communities".into()))?;
3053        let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
3054        let id = if private {
3055            crate::community::v2::service::create_private_channel(&transport, &v2, name).await
3056        } else {
3057            crate::community::v2::service::create_public_channel(&transport, &v2, name).await
3058        }
3059        .map_err(VectorError::Other)?;
3060        // Subscribe the new channel's chat plane now — waiting on the round-trip of
3061        // our own vsk-2 edition would leave the creator deaf to first replies.
3062        if let Some(client) = state::nostr_client() {
3063            crate::community::v2::realtime::refresh_subscription(&client).await;
3064        }
3065        Ok(crate::simd::hex::bytes_to_hex_32(&id.0))
3066    }
3067
3068    /// Delete (tombstone) a v2 community channel. Requires MANAGE_CHANNELS (reader-gated).
3069    pub async fn delete_community_channel(&self, community_id: &str, channel_id: &str) -> Result<()> {
3070        let v2 = Self::load_v2_if_v2(community_id)?
3071            .ok_or_else(|| VectorError::Other("channel deletion is available on v2 communities".into()))?;
3072        let ch = crate::community::ChannelId(crate::simd::hex::hex_to_bytes_32(channel_id));
3073        let name = v2.channels.iter().find(|c| c.id.0 == ch.0).map(|c| c.name.clone()).unwrap_or_default();
3074        let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
3075        crate::community::v2::service::delete_channel(&transport, &v2, &ch, &name)
3076            .await
3077            .map_err(VectorError::Other)
3078    }
3079
3080    /// Leave a Community: announce a best-effort "left" presence (before dropping keys), then
3081    /// drop the held keys + local channel chats. You need a fresh invite to rejoin.
3082    pub async fn leave_community(&self, community_id: &str) -> Result<()> {
3083        use crate::community::{transport::LiveTransport, CommunityId};
3084        if community_id.len() != 64 {
3085            return Err(VectorError::Other("malformed community id".into()));
3086        }
3087        let id = CommunityId(crate::simd::hex::hex_to_bytes_32(community_id));
3088        // v2: guestbook Leave + cross-device List tombstone + local delete, in the service.
3089        if let Some(v2) = Self::load_v2_if_v2(community_id)? {
3090            let session = state::SessionGuard::capture();
3091            let channel_ids: Vec<String> =
3092                v2.channels.iter().map(|ch| crate::simd::hex::bytes_to_hex_32(&ch.id.0)).collect();
3093            let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
3094            crate::community::v2::service::leave_community(&transport, &v2)
3095                .await
3096                .map_err(VectorError::Other)?;
3097            if !session.is_valid() {
3098                return Err(VectorError::Other("account changed during leave".into()));
3099            }
3100            let mut st = state::STATE.lock().await;
3101            st.chats.retain(|c| !channel_ids.contains(&c.id));
3102            return Ok(());
3103        }
3104        let community = crate::db::community::load_community(&id).map_err(VectorError::Other)?;
3105        let channel_ids: Vec<String> = community
3106            .as_ref()
3107            .map(|c| c.channels.iter().map(|ch| ch.id.to_hex()).collect())
3108            .unwrap_or_default();
3109        // "Left" announcement BEFORE dropping keys (afterward we can't sign/seal into the channel).
3110        if let Some(ref c) = community {
3111            if let Some(primary) = c.channels.first() {
3112                let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
3113                let _ = crate::community::service::publish_presence(&transport, c, primary, false, None).await;
3114            }
3115        }
3116        // voluntary leave is a self-removal → retain the held epoch keys for later self-scrub.
3117        crate::db::community::delete_community_retain_keys(community_id).map_err(VectorError::Other)?;
3118        {
3119            let mut st = state::STATE.lock().await;
3120            st.chats.retain(|c| !channel_ids.contains(&c.id));
3121        }
3122        Ok(())
3123    }
3124
3125    /// Resolve a channel id to its owning Community + the Channel (with its secret key).
3126    fn resolve_channel(
3127        &self,
3128        channel_id: &str,
3129    ) -> Result<(crate::community::Community, crate::community::Channel)> {
3130        use crate::community::CommunityId;
3131        let community_id = crate::db::community::community_id_for_channel(channel_id)
3132            .map_err(VectorError::Other)?
3133            .ok_or_else(|| VectorError::Other("Unknown Community channel".into()))?;
3134        if community_id.len() != 64 {
3135            return Err(VectorError::Other("malformed community id".into()));
3136        }
3137        let community = crate::db::community::load_community(&CommunityId(
3138            crate::simd::hex::hex_to_bytes_32(&community_id),
3139        ))
3140        .map_err(VectorError::Other)?
3141        .ok_or_else(|| VectorError::Other("Community not found".into()))?;
3142        let channel = community
3143            .channels
3144            .iter()
3145            .find(|c| c.id.to_hex() == channel_id)
3146            .cloned()
3147            .ok_or_else(|| VectorError::Other("Channel not found in Community".into()))?;
3148        Ok((community, channel))
3149    }
3150
3151
3152    /// Sync DM history from relays using NIP-77 negentropy set reconciliation.
3153    ///
3154    /// Reconciles local wrapper history with relay state, fetches missing events,
3155    /// and processes them through the standard prepare → commit pipeline.
3156    ///
3157    /// Returns (total_events, new_messages).
3158    ///
3159    /// ```no_run
3160    /// # async fn example() -> vector_core::Result<()> {
3161    /// let core = vector_core::VectorCore;
3162    /// // Sync last 7 days of DMs
3163    /// let (events, new) = core.sync_dms(Some(7), &vector_core::NoOpEventHandler).await?;
3164    /// println!("Processed {} events, {} new messages", events, new);
3165    /// # Ok(())
3166    /// # }
3167    /// ```
3168    pub async fn sync_dms(
3169        &self,
3170        since_days: Option<u64>,
3171        handler: &dyn InboundEventHandler,
3172    ) -> Result<(u32, u32)> {
3173        use futures_util::StreamExt;
3174        use nostr_sdk::prelude::*;
3175
3176        let client = state::nostr_client()
3177            .ok_or(VectorError::Other("Not connected".into()))?;
3178        let my_pk = state::my_public_key()
3179            .ok_or(VectorError::Other("Not logged in".into()))?;
3180
3181        // Load known wrapper IDs + timestamps for negentropy fingerprinting
3182        let all_items = db::wrappers::load_negentropy_items().unwrap_or_default();
3183
3184        // Filter items to time window (or use all for full sync)
3185        let (items, filter) = if let Some(days) = since_days {
3186            let since_ts = Timestamp::now().as_secs().saturating_sub(days * 24 * 3600);
3187            let items: Vec<(EventId, Timestamp)> = all_items.iter()
3188                .filter(|(_, ts)| ts.as_secs() >= since_ts)
3189                .cloned()
3190                .collect();
3191            let filter = Filter::new()
3192                .pubkey(my_pk)
3193                .kind(Kind::GiftWrap)
3194                .since(Timestamp::from_secs(since_ts));
3195            (items, filter)
3196        } else {
3197            let filter = Filter::new()
3198                .pubkey(my_pk)
3199                .kind(Kind::GiftWrap);
3200            (all_items, filter)
3201        };
3202
3203        log_info!("[SyncDMs] {} negentropy items, since_days={:?}", items.len(), since_days);
3204
3205        // Dry-run negentropy: exchange fingerprints to identify missing events
3206        let sync_opts = nostr_sdk::prelude::SyncOptions::new()
3207            .direction(nostr_sdk::prelude::SyncDirection::Down)
3208            .initial_timeout(std::time::Duration::from_secs(10))
3209            .dry_run();
3210
3211        // Race all relays — first to reconcile drives the fetch. Relays with a
3212        // fresh no-NIP-77 verdict skip the doomed reconcile and get a bounded
3213        // REQ pass below instead.
3214        let relay_map = client.relays().await;
3215        let (all_relays, no_neg_relays): (Vec<(RelayUrl, Relay)>, Vec<(RelayUrl, Relay)>) =
3216            relay_map.iter()
3217                .map(|(url, relay)| (url.clone(), relay.clone()))
3218                .partition(|(url, _)| negentropy::neg_supported_cached(url.as_str()) != Some(false));
3219        drop(relay_map);
3220        let skipped_no_neg: Vec<String> = no_neg_relays.iter().map(|(u, _)| u.to_string()).collect();
3221        if !skipped_no_neg.is_empty() {
3222            log_info!("[SyncDMs] {} relay(s) on REQ path (no NIP-77)", skipped_no_neg.len());
3223        }
3224
3225        // Tor-aware like the GUI path — a fixed clearnet budget over Tor makes
3226        // a healthy relay's slow first frame look like connected-silence and
3227        // earns it a false 24h no-NEG verdict in the shared account KV.
3228        let neg_budget = relay_request_timeout(std::time::Duration::from_secs(10));
3229        let neg_outer = neg_budget + std::time::Duration::from_secs(5);
3230        let connect_allowance = relay_request_timeout(std::time::Duration::from_secs(3))
3231            .min(neg_outer);
3232        let mut relay_futs = futures_util::stream::FuturesUnordered::new();
3233        for (url, relay) in &all_relays {
3234            let url = url.clone();
3235            let relay = relay.clone();
3236            let f = filter.clone();
3237            let i = items.clone();
3238            let o = sync_opts.clone();
3239            relay_futs.push(async move {
3240                if !negentropy::wait_connected(&relay, connect_allowance).await {
3241                    return (url, None, false);
3242                }
3243                // Outer slack over the initial_timeout so the SDK's error
3244                // (which distinguishes refusal from silence) surfaces first.
3245                let result = tokio::time::timeout(
3246                    neg_outer,
3247                    relay.sync(f).items(i).opts(o),
3248                ).await;
3249                let connected = relay.status() == RelayStatus::Connected;
3250                (url, Some(result), connected)
3251            });
3252        }
3253
3254        // Collect missing IDs from all relays
3255        let cap_session = state::SessionGuard::capture();
3256        let mut all_missing: std::collections::HashSet<EventId> = std::collections::HashSet::new();
3257        while let Some((url, result, connected)) = relay_futs.next().await {
3258            let Some(result) = result else {
3259                log_warn!("[SyncDMs] {} skipped: not connected", url);
3260                continue;
3261            };
3262            match result {
3263                Ok(Ok(recon)) => {
3264                    let count = recon.remote.len();
3265                    all_missing.extend(recon.remote);
3266                    log_info!("[SyncDMs] {} reconciled: {} missing", url, count);
3267                    if cap_session.is_valid() {
3268                        negentropy::record_neg_support(url.as_str(), true);
3269                    }
3270                }
3271                Ok(Err(e)) => {
3272                    log_warn!("[SyncDMs] {} failed: {}", url, e);
3273                    if cap_session.is_valid()
3274                        && negentropy::classify_neg_sync_error(&e.to_string(), connected) == Some(false)
3275                    {
3276                        log_info!("[SyncDMs] {} marked no-NIP-77 for 24h", url);
3277                        negentropy::record_neg_support(url.as_str(), false);
3278                    }
3279                }
3280                Err(_) => log_warn!("[SyncDMs] {} timed out ({:?})", url, neg_outer),
3281            }
3282        }
3283
3284        let mut total_events = 0u32;
3285        let mut new_messages = 0u32;
3286
3287        // No-NIP-77 relays still contribute: one bounded REQ over the same
3288        // filter. The 500-event cap keeps a `since_days: None` call from
3289        // pulling a whole mailbox — deep history is negentropy's job on the
3290        // relays that speak it.
3291        if !skipped_no_neg.is_empty() {
3292            let req_filter = filter.clone().limit(500);
3293            match client
3294                .stream_events(nostr_sdk::prelude::ReqTarget::manual(
3295                    skipped_no_neg.iter().cloned().map(|u| (u, vec![req_filter.clone()])),
3296                ))
3297                .timeout(std::time::Duration::from_secs(20))
3298                .await
3299            {
3300                Ok(stream) => {
3301                    let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
3302                    tokio::pin!(stream);
3303                    while let Some((_relay, res)) = stream.next().await {
3304                        let Ok(event) = res else { continue };
3305                        // Straddles the stream: a swap mid-drain must not push
3306                        // the old account's wrappers through the new account's
3307                        // pipeline (ErrorSkip would ledger them there).
3308                        if !cap_session.is_valid() { break; }
3309                        if !seen.insert(event.id.to_bytes()) { continue; }
3310                        total_events += 1;
3311                        let prepared = event_handler::prepare_event(event, &client, my_pk).await;
3312                        if event_handler::commit_prepared_event(prepared, false, handler).await {
3313                            new_messages += 1;
3314                        }
3315                    }
3316                }
3317                Err(e) => log_warn!("[SyncDMs] REQ pass failed: {}", e),
3318            }
3319        }
3320
3321        if all_missing.is_empty() {
3322            log_info!("[SyncDMs] No missing events");
3323            return Ok((total_events, new_messages));
3324        }
3325
3326        // Fetch missing events in batches
3327        log_info!("[SyncDMs] Fetching {} missing events", all_missing.len());
3328        let ids: Vec<EventId> = all_missing.into_iter().collect();
3329        let relay_strs: Vec<String> = client.relays().await.keys()
3330            .map(|u| u.to_string()).collect();
3331
3332        const BATCH_SIZE: usize = 500;
3333
3334        for batch in ids.chunks(BATCH_SIZE) {
3335            // The #p is not redundant: Ditto refuses gift-wrap REQs that carry
3336            // neither authors nor #p, even authed — ids-only returns nothing.
3337            let f = Filter::new().ids(batch.to_vec()).kind(Kind::GiftWrap).pubkey(my_pk);
3338            match client
3339                .stream_events(nostr_sdk::prelude::ReqTarget::manual(
3340                    relay_strs.iter().cloned().map(|u| (u, vec![f.clone()])),
3341                ))
3342                .timeout(std::time::Duration::from_secs(30))
3343                .await
3344            {
3345                Ok(stream) => {
3346                    let client_clone = client.clone();
3347                    let prepared_stream = stream
3348                        .filter_map(|(_relay, res)| async move { res.ok() })
3349                        .map(move |event| {
3350                            let c = client_clone.clone();
3351                            tokio::spawn(async move {
3352                                event_handler::prepare_event(event, &c, my_pk).await
3353                            })
3354                        })
3355                        .buffer_unordered(8);
3356                    tokio::pin!(prepared_stream);
3357
3358                    while let Some(result) = prepared_stream.next().await {
3359                        total_events += 1;
3360                        if let Ok(prepared) = result {
3361                            if event_handler::commit_prepared_event(prepared, false, handler).await {
3362                                new_messages += 1;
3363                            }
3364                        }
3365                    }
3366                }
3367                Err(e) => log_warn!("[SyncDMs] Batch fetch error: {}", e),
3368            }
3369        }
3370
3371        log_info!("[SyncDMs] Complete: {} events processed, {} new messages", total_events, new_messages);
3372        Ok((total_events, new_messages))
3373    }
3374
3375    // ========================================================================
3376    // Event Subscription
3377    // ========================================================================
3378
3379    /// Subscribe to incoming DM events (NIP-17 GiftWraps).
3380    ///
3381    /// Returns the subscription ID for use in a custom notification loop.
3382    /// For a complete listen-and-process loop, use [`listen()`](Self::listen) instead.
3383    pub async fn subscribe_dms(&self) -> Result<nostr_sdk::prelude::SubscriptionId> {
3384        use nostr_sdk::prelude::*;
3385        let client = state::nostr_client()
3386            .ok_or(VectorError::Other("Not connected".into()))?;
3387        let my_pk = state::my_public_key()
3388            .ok_or(VectorError::Other("Not logged in".into()))?;
3389
3390        let filter = Filter::new()
3391            .pubkey(my_pk)
3392            .kind(Kind::GiftWrap)
3393            .limit(0);
3394
3395        let output = client.subscribe(filter).await
3396            .map_err(|e| VectorError::Nostr(e.to_string()))?;
3397        Ok(output.value)
3398    }
3399
3400    /// Catch up every locally-held Community: fold control / re-foundings / rekeys / banlist and
3401    /// fetch recent messages into local state for each channel. State-only (does not replay to an
3402    /// [`InboundEventHandler`]). Called at `listen()` start and periodically for outage resilience;
3403    /// also safe to call manually after a known disconnect.
3404    ///
3405    /// Catch up every locally-held Community. v1 channels are synced inline; a v2
3406    /// community is ENQUEUED for the follow worker (control/rekey re-fold + adopt),
3407    /// non-blocking. State-only (no handler replay of history). Called at `listen()`
3408    /// start and on reconnect; safe to call manually — the v2 enqueue is a no-op if
3409    /// no `listen()` worker is running.
3410    pub async fn sync_communities(&self) -> Result<()> {
3411        // Discover + rehydrate memberships from the 13302 across devices (CORD-02 §8),
3412        // bootstrapping from the client's connected relays so even a fresh device that
3413        // holds no community yet can find them. Best-effort.
3414        {
3415            use crate::community::{transport::LiveTransport, v2::service as v2};
3416            let bootstrap: Vec<String> = match crate::state::nostr_client() {
3417                Some(client) => client.relays().await.keys().map(|r| r.to_string()).collect(),
3418                None => Vec::new(),
3419            };
3420            let transport = LiveTransport::with_timeout(std::time::Duration::from_secs(12));
3421            if let Ok(outcome) = v2::sync_community_list(&transport, &bootstrap).await {
3422                // Headless: core already dropped the rows; a GUI shell additionally clears the
3423                // chat rows + STATE via `removed` (see `ListSyncOutcome`).
3424                let joined = outcome.joined;
3425                for c in &joined {
3426                    if community::v2::realtime::follow_worker_running() {
3427                        community::v2::realtime::enqueue_follow(c.id());
3428                    } else {
3429                        let _ = Self::v2_inline_follow(c.id()).await;
3430                    }
3431                }
3432                if !joined.is_empty() {
3433                    if let Some(client) = crate::state::nostr_client() {
3434                        community::v2::realtime::refresh_subscription(&client).await;
3435                    }
3436                }
3437            }
3438        }
3439
3440        let ids = db::community::list_community_ids().map_err(VectorError::from)?;
3441        for id in ids {
3442            if matches!(db::community::community_protocol(&id).ok().flatten(), Some(crate::community::ConcordProtocol::V2)) {
3443                // With a live listen() the coalescing worker owns the follow; headless
3444                // (no worker) it would be dropped, so walk it inline instead.
3445                if community::v2::realtime::follow_worker_running() {
3446                    community::v2::realtime::enqueue_follow(&id);
3447                } else {
3448                    let _ = Self::v2_inline_follow(&id).await;
3449                }
3450                continue;
3451            }
3452            if let Ok(Some(community)) = db::community::load_community(&id) {
3453                for ch in &community.channels {
3454                    let _ = self.sync_community_channel(&ch.id.to_hex(), 50).await;
3455                }
3456            }
3457        }
3458        Ok(())
3459    }
3460
3461
3462    /// Start listening for incoming DMs.
3463    ///
3464    /// Blocks until the client disconnects. Processes GiftWraps
3465    /// (DMs, files) → prepare_event → commit_prepared_event.
3466    ///
3467    /// ```no_run
3468    /// use vector_core::*;
3469    /// use std::sync::Arc;
3470    ///
3471    /// struct MyBot;
3472    /// impl InboundEventHandler for MyBot {
3473    ///     fn on_dm_received(&self, chat_id: &str, msg: &Message, _is_new: bool) {
3474    ///         if msg.mine { return; }
3475    ///         let to = chat_id.to_string();
3476    ///         let reply = format!("Echo: {}", msg.content);
3477    ///         tokio::spawn(async move {
3478    ///             let _ = VectorCore.send_dm(&to, &reply).await;
3479    ///         });
3480    ///     }
3481    /// }
3482    ///
3483    /// # async fn example() -> vector_core::Result<()> {
3484    /// let core = VectorCore::init(CoreConfig {
3485    ///     data_dir: "/tmp/bot-data".into(),
3486    ///     event_emitter: None,
3487    /// })?;
3488    /// core.login("nsec1...", None).await?;
3489    /// core.listen(Arc::new(MyBot)).await?;
3490    /// # Ok(())
3491    /// # }
3492    /// ```
3493    pub async fn listen(&self, handler: Arc<dyn InboundEventHandler>) -> Result<()> {
3494        use nostr_sdk::prelude::*;
3495
3496        let client = state::nostr_client()
3497            .ok_or(VectorError::Other("Not connected".into()))?;
3498        let my_pk = state::my_public_key()
3499            .ok_or(VectorError::Other("Not logged in".into()))?;
3500
3501        // Start the stream-AUTH responder BEFORE any relay interaction: a gating
3502        // relay issues its NIP-42 challenge ONCE per connection, and the DM
3503        // subscribe below consumes it via nostr-sdk's user auto-auth — if the
3504        // responder isn't already watching, that challenge is never remembered
3505        // and the stream keys registered later can NEVER authenticate (the relay
3506        // won't re-challenge an authed connection; the v2 sub dies silently).
3507        community::v2::streamauth::ensure_responder(&client);
3508
3509        // Outage resilience — catch up on connect, then re-sync periodically.
3510        //
3511        // Catch up BEFORE going realtime so a bot that was offline folds any missed re-foundings /
3512        // metadata / banlist changes (and recent messages) into local state, and subscribes at the
3513        // CURRENT epoch pseudonyms. This is state-only: historical messages are not replayed to the
3514        // handler (matches the gateway model) — query them via `get_messages`.
3515        // Spawn the single per-community follow worker for this session; the v2
3516        // follow queue (fed by dispatch, catch-up, and sync) drains through it.
3517        community::v2::realtime::spawn_follow_worker(handler.clone());
3518        let _ = self.sync_communities().await;
3519        let _ = self.sync_dms(None, &NoOpEventHandler).await;
3520
3521        // Subscribe to DMs (GiftWraps) AND Community channel events — one loop dispatches both
3522        // through the same handler, so `on_dm_received`/`on_community_message` share a sink.
3523        let dm_sub_id = self.subscribe_dms().await?;
3524        community::realtime::refresh_subscription(&client).await;
3525        community::v2::realtime::refresh_subscription(&client).await;
3526
3527        // Outage resilience via the relay Monitor — event-driven, not polling.
3528        //
3529        // (1) Reconnect-driven catch-up: a `limit(0)` realtime sub never replays what was published
3530        // while we were down, so a relay (re)connecting is exactly when we must catch up. On each
3531        // Connected transition we refold consensus + reconcile DMs (NIP-77 negentropy → only the
3532        // diff) and re-track the realtime sub at the current epochs. Idle when healthy. Stops on swap.
3533        if let Some(monitor) = client.monitor() {
3534            let mut rx = monitor.subscribe();
3535            let session = state::SessionGuard::capture();
3536            tokio::spawn(async move {
3537                // Debounce reconnect bursts: StatusChanged is per-relay, but one catch-up queries the
3538                // whole pool — so coalesce Connected transitions within a short window into one resync.
3539                let mut last_resync: Option<std::time::Instant> = None;
3540                while let Ok(notification) = rx.recv().await {
3541                    if !session.is_valid() {
3542                        return;
3543                    }
3544                    let MonitorNotification::StatusChanged { status, .. } = notification;
3545                    if status == RelayStatus::Connected {
3546                        if last_resync.is_some_and(|t| t.elapsed() < std::time::Duration::from_secs(3)) {
3547                            continue;
3548                        }
3549                        let _ = VectorCore.sync_communities().await;
3550                        let _ = VectorCore.sync_dms(None, &NoOpEventHandler).await;
3551                        if let Some(c) = state::nostr_client() {
3552                            community::realtime::refresh_subscription(&c).await;
3553                            community::v2::realtime::refresh_subscription(&c).await;
3554                        }
3555                        last_resync = Some(std::time::Instant::now());
3556                    }
3557                }
3558            });
3559        }
3560
3561        // (2) Health probe: a relay can report Connected while silently dead. Every 60s probe each
3562        // with a tiny query + timeout; a zombie is force-reconnected (which fires the monitor above
3563        // → catch-up), and Disconnected/Terminated relays are reconnected directly.
3564        {
3565            let client_health = client.clone();
3566            let session = state::SessionGuard::capture();
3567            tokio::spawn(async move {
3568                tokio::time::sleep(std::time::Duration::from_secs(30)).await; // warm-up
3569                loop {
3570                    if !session.is_valid() {
3571                        return;
3572                    }
3573                    for (url, relay) in client_health.relays().await {
3574                        match relay.status() {
3575                            RelayStatus::Connected => {
3576                                let probe = tokio::time::timeout(
3577                                    std::time::Duration::from_secs(10),
3578                                    client_health
3579                                        .fetch_events(nostr_sdk::prelude::ReqTarget::single(
3580                                            url.to_string(),
3581                                            [Filter::new().kind(Kind::Metadata).limit(1)],
3582                                        ))
3583                                        .timeout(std::time::Duration::from_secs(8)),
3584                                )
3585                                .await;
3586                                if !matches!(probe, Ok(Ok(_))) {
3587                                    let _ = relay.disconnect();
3588                                    tokio::time::sleep(std::time::Duration::from_millis(500)).await;
3589                                    let _ = relay.try_connect().timeout(crate::relay_connect_timeout(std::time::Duration::from_secs(10))).await;
3590                                }
3591                            }
3592                            RelayStatus::Terminated | RelayStatus::Disconnected => {
3593                                let _ = relay.try_connect().timeout(crate::relay_connect_timeout(std::time::Duration::from_secs(10))).await;
3594                            }
3595                            _ => {}
3596                        }
3597                    }
3598                    tokio::time::sleep(std::time::Duration::from_secs(60)).await;
3599                }
3600            });
3601        }
3602
3603        let client_for_closure = client.clone();
3604
3605        // 0.45 removed `handle_notifications`; drive the stream directly. It ends when
3606        // the client shuts down, which is what stops this loop on `swap_session`.
3607        let mut notifications = client.notifications();
3608        while let Some(notification) = notifications.next().await {
3609            let handler = handler.clone();
3610            let c = client_for_closure.clone();
3611            let dm_sid = dm_sub_id.clone();
3612            {
3613                // Relay OKs feed the send pipeline: an OK that outlives the
3614                // per-attempt wait still confirms delivery, and can rescue a
3615                // message already marked Failed.
3616                if let nostr_sdk::prelude::ClientNotification::Message { message, .. } = &notification {
3617                    if let nostr_sdk::prelude::RelayMessage::Ok { event_id, status, .. } = &**message {
3618                        sending::note_relay_ok(event_id, *status);
3619                    }
3620                }
3621                if let nostr_sdk::prelude::ClientNotification::Event { event, subscription_id, .. } = notification {
3622                    if subscription_id == dm_sid {
3623                        // DMs, files, reactions
3624                        let prepared = event_handler::prepare_event(*event, &c, my_pk).await;
3625                        event_handler::commit_prepared_event(prepared, true, &*handler).await;
3626                    } else if community::realtime::subscription_id().await.as_ref() == Some(&subscription_id)
3627                        || community::realtime::poolwide_subscription_id().await.as_ref() == Some(&subscription_id)
3628                    {
3629                        // Community (v1) channel messages / reactions / edits / control editions.
3630                        // OR the pool-wide sub (the path that streams on Android) — else v1 events
3631                        // arriving under it match no branch and are silently dropped.
3632                        let session = state::SessionGuard::capture();
3633                        community::realtime::dispatch_event(&session, *event, handler.clone()).await;
3634                    } else if community::v2::realtime::subscription_id().await.as_ref() == Some(&subscription_id)
3635                        || community::v2::realtime::poolwide_subscription_id().await.as_ref() == Some(&subscription_id)
3636                    {
3637                        // Concord v2 plane events (authors-addressed kind-1059/21059).
3638                        let session = state::SessionGuard::capture();
3639                        community::v2::realtime::dispatch_event(&session, *event, handler.clone()).await;
3640                    }
3641                }
3642            }
3643        }
3644
3645        Ok(())
3646    }
3647
3648    /// Disconnect and clean up.
3649    pub async fn logout(&self) {
3650        if let Some(client) = state::nostr_client() {
3651            let _ = client.disconnect().await;
3652        }
3653        db::close_database();
3654    }
3655
3656    /// Tear down the current session for an in-process account swap — the account-agnostic core of
3657    /// the app's `reset_session()`. Advances the session generation FIRST so any background task
3658    /// holding a `SessionGuard` short-circuits before it can touch the next account's storage; shuts
3659    /// the client down (which ends any `listen()` notification loop bound to it, so the old account's
3660    /// events can't land in the new account's DB); closes the DB pool; and clears the key vaults plus
3661    /// all in-memory per-account state. Follow with `login()` to bind the next account, then re-attach
3662    /// `listen()`. (The app's `reset_session()` additionally clears Tauri-only caches it owns.)
3663    pub async fn swap_session(&self) {
3664        // FIRST — invalidate every captured guard before any teardown begins.
3665        state::bump_session_generation();
3666
3667        // Shut the client down before anything else: this detaches relay subscriptions and ends the
3668        // prior `listen()` loop, so it stops firing the old account's events into the new session.
3669        if let Some(client) = state::take_nostr_client() {
3670            let _ = client.shutdown().await;
3671        }
3672        db::close_database();
3673
3674        // Key vaults + transient secrets.
3675        state::ENCRYPTION_KEY.clear(&[&state::MY_SECRET_KEY]);
3676        state::MY_SECRET_KEY.clear(&[&state::ENCRYPTION_KEY]);
3677        {
3678            use zeroize::Zeroize;
3679            if let Ok(mut g) = state::MNEMONIC_SEED.lock() {
3680                if let Some(s) = g.as_mut() { s.zeroize(); }
3681                *g = None;
3682            }
3683            if let Ok(mut g) = state::PENDING_NSEC.lock() {
3684                if let Some(s) = g.as_mut() { s.zeroize(); }
3685                *g = None;
3686            }
3687        }
3688
3689        // In-memory per-account state owned by vector-core's globals.
3690        {
3691            let mut st = state::STATE.lock().await;
3692            st.profiles.clear();
3693            st.chats.clear();
3694            st.db_loaded = false;
3695            st.is_syncing = false;
3696        }
3697        state::WRAPPER_ID_CACHE.lock().await.clear();
3698        state::PENDING_EVENTS.lock().await.clear();
3699        state::set_active_chat(None);
3700        crate::profile::sync::clear_profile_sync_queue();
3701        crate::inbox_relays::clear_inbox_relay_cache();
3702        // In-flight wrap confirmations carry the prior account's chat and
3703        // message ids — a late OK must not "rescue" into the new session.
3704        crate::sending::clear_wrap_confirms();
3705        crate::emoji_packs::clear_nip65_cache();
3706        // Chat/user row-id caches are PER-ACCOUNT (row ids belong to the prior account's DB). Not clearing
3707        // them here let a swapped-in account resolve a channel/npub to the WRONG (prior-account) row id →
3708        // saves FK-failed silently + reads hit the wrong row (e.g. a community member vanished post-swap).
3709        crate::db::clear_id_caches();
3710        // Community sync RAM cache (page cursors, history-start, in-flight, invite preload) is
3711        // account-scoped — drop it so the next account can't read A's cursors/warmed pages. The
3712        // generation stamp self-invalidates too, but clear explicitly for parity with the GUI swap.
3713        crate::community::cache::clear();
3714        // Community realtime route/subscription state is account-scoped (channel keys + banned sets);
3715        // drop it so a swapped-in account can't listen on the prior account's pseudonyms.
3716        crate::community::realtime::clear().await;
3717        crate::community::v2::realtime::clear().await;
3718        // Pooled plane connections are authed as the prior account's plane secret keys.
3719        crate::community::transport::clear_plane_pool();
3720        // Theme-pack emoji tags are account-scoped; leaving the prior account's set active would tag the
3721        // next account's outbound messages with A's theme shortcodes (leaking A's pack Blossom URLs). The
3722        // frontend re-registers the new account's theme, but only if it HAS one — clear to be safe.
3723        crate::emoji_packs::set_theme_emoji_tags(Vec::new());
3724    }
3725}
3726
3727#[cfg(all(test, feature = "tor", not(target_arch = "wasm32")))]
3728mod transport_policy_tests {
3729    use std::time::Duration;
3730
3731    /// ONE test covering proxy + budgets: the Tor preference is a process-global
3732    /// atomic, so separate `#[test]` fns would race under the parallel runner.
3733    #[test]
3734    fn tor_transport_policy() {
3735        let short = Duration::from_secs(5);
3736        let long = Duration::from_secs(300);
3737
3738        // Tor off: connections may go direct, and every caller's clearnet budget
3739        // passes through untouched so the common path is never slowed down.
3740        crate::tor::set_tor_enabled_pref(false);
3741        assert_eq!(super::tor_proxy_target(), None);
3742        assert_eq!(super::relay_connect_timeout(short), short);
3743        assert_eq!(super::relay_request_timeout(short), short);
3744
3745        // `RequiredButInactive` (Tor chosen, proxy not up yet) must raise the floor
3746        // just like `Active`: that window is when connects are slowest, and treating
3747        // it as clearnet is what tore relays down mid-handshake.
3748        crate::tor::set_tor_enabled_pref(true);
3749        assert!(matches!(
3750            crate::tor::transport_state(),
3751            crate::tor::TorTransportState::RequiredButInactive
3752        ));
3753        // THE leak invariant: `None` here means "connect direct". While Tor is the
3754        // chosen transport it must never be None — least of all during bootstrap,
3755        // which is exactly when a naive implementation falls through to direct.
3756        // Silent failure with an IP disclosure as the cost, so it gets a permanent
3757        // guard rather than a one-off manual check.
3758        assert_eq!(
3759            super::tor_proxy_target(),
3760            Some(crate::tor::blackhole_proxy_addr()),
3761            "Tor enabled but inactive must blackhole, never connect direct"
3762        );
3763        assert_eq!(super::relay_connect_timeout(short), super::TOR_RELAY_CONNECT_FLOOR);
3764        assert_eq!(super::relay_request_timeout(short), super::TOR_RELAY_REQUEST_FLOOR);
3765
3766        // The floor only ever raises. A caller asking for longer than the floor has
3767        // a reason to, and shortening it would abort operations that used to finish.
3768        for tor in [true, false] {
3769            crate::tor::set_tor_enabled_pref(tor);
3770            assert_eq!(super::relay_connect_timeout(long), long, "connect, tor={tor}");
3771            assert_eq!(super::relay_request_timeout(long), long, "request, tor={tor}");
3772        }
3773    }
3774}
3775
3776#[cfg(test)]
3777mod facade_tests {
3778    use super::*;
3779
3780    /// SSRF regression: `download_attachment` must reject a private/link-local URL via
3781    /// `validate_url_not_private` BEFORE any network fetch (the URL is attacker-controlled).
3782    #[tokio::test]
3783    async fn download_attachment_rejects_private_url() {
3784        let att = crate::types::Attachment {
3785            url: "http://169.254.169.254/latest/meta-data/".to_string(),
3786            ..Default::default()
3787        };
3788        match VectorCore.download_attachment(&att).await {
3789            Err(VectorError::Other(msg)) => {
3790                assert!(msg.contains("Private/internal"), "expected SSRF rejection, got: {msg}")
3791            }
3792            other => panic!("expected SSRF rejection, got {other:?}"),
3793        }
3794    }
3795
3796    #[tokio::test]
3797    async fn download_attachment_rejects_empty_url() {
3798        let att = crate::types::Attachment::default();
3799        assert!(VectorCore.download_attachment(&att).await.is_err());
3800    }
3801
3802    /// The facade dual-stack dispatch: a v2 community surfaces in `list_communities`
3803    /// with `version: 2`, and `v2_community_for_channel` routes its channels to the
3804    /// v2 send path — while a v1 community is untouched (version 1).
3805    #[tokio::test]
3806    async fn list_communities_and_channel_routing_are_protocol_aware() {
3807        use crate::community::transport::memory::MemoryRelay;
3808        use nostr_sdk::prelude::Keys;
3809
3810        let _guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
3811        crate::db::close_database();
3812        crate::db::clear_id_caches();
3813        let tmp = tempfile::tempdir().unwrap();
3814        // A valid bech32-charset, npub-length account dir name.
3815        let acct = {
3816            const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
3817            let mut s = String::from("npub1");
3818            for i in 0..58 {
3819                s.push(B[(i * 7 + 3) % 32] as char);
3820            }
3821            s
3822        };
3823        std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
3824        crate::db::set_app_data_dir(tmp.path().to_path_buf());
3825        crate::db::set_current_account(acct.clone()).unwrap();
3826        crate::db::init_database(&acct).unwrap();
3827        let _ = crate::state::take_nostr_client();
3828        let me = Keys::generate();
3829        crate::state::MY_SECRET_KEY.store_from_keys(&me, &[]);
3830        crate::state::set_my_public_key(me.public_key());
3831
3832        // Create a v2 community directly through the v2 service (offline).
3833        let relay = MemoryRelay::new();
3834        let community = crate::community::v2::service::create_community(&relay, "V2 Guild", vec!["wss://r".into()], None)
3835            .await
3836            .unwrap();
3837        let channel_hex = crate::simd::hex::bytes_to_hex_32(&community.channels[0].id.0);
3838
3839        // The facade lists it as version 2, owned by me.
3840        let listed = VectorCore.list_communities().await;
3841        let v2 = listed.iter().find(|c| c["version"] == 2).expect("the v2 community is listed");
3842        assert_eq!(v2["name"], "V2 Guild");
3843        assert_eq!(v2["is_owner"], true);
3844        assert_eq!(v2["channels"][0]["channel_id"], channel_hex);
3845
3846        // The channel routes to the v2 send path.
3847        assert_eq!(
3848            VectorCore.v2_community_for_channel(&channel_hex).unwrap(),
3849            Some(community.identity.community_id),
3850            "a v2 channel is routed to v2"
3851        );
3852        // An unknown channel routes nowhere (would fall through to v1).
3853        assert_eq!(VectorCore.v2_community_for_channel(&"00".repeat(32)).unwrap(), None);
3854    }
3855
3856    /// The facade builds a v2 invite URL by trimming `/invite` off the v1
3857    /// constant (v2's `build_invite_url` re-appends its own `/invite/<naddr>`).
3858    /// Lock that the derived URL is v2-shaped and round-trips through the v2
3859    /// parser — a stale constant or a double-`/invite` would silently break joins.
3860    #[test]
3861    fn v2_invite_url_base_derivation_round_trips() {
3862        use crate::community::v2::derive::TOKEN_LEN;
3863        use crate::community::v2::invite::{build_invite_url, parse_invite_link};
3864        use nostr_sdk::prelude::Keys;
3865        let base = crate::community::public_invite::INVITE_URL_BASE.trim_end_matches("/invite");
3866        assert!(!base.ends_with("/invite"), "the bare domain must not carry /invite");
3867        let signer = Keys::generate();
3868        let token = [0x07u8; TOKEN_LEN];
3869        let url = build_invite_url(base, &signer.public_key(), &token, &[]).unwrap();
3870        assert!(url.contains("/invite/"), "a v2 URL carries the naddr path");
3871        assert!(!url.contains("/invite/invite/"), "no doubled /invite from the base");
3872        let parsed = parse_invite_link(&url).unwrap();
3873        assert_eq!(parsed.link_signer, signer.public_key());
3874        assert_eq!(parsed.token, token);
3875    }
3876}