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