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