Skip to main content

vector_core/community/v2/
realtime.rs

1//! v2 realtime — the `authors`-based live subscription + dispatch.
2//!
3//! v2 addresses planes by their group pubkey (CORD-01), not v1's `#z` tag, so
4//! the subscription is `{kinds:[1059,21059], authors:[…plane pubkeys…]}`. A
5//! received event is routed to the owning community and handed to the shared
6//! [`inbound::dispatch_wrap`], which fires the protocol-agnostic
7//! `InboundEventHandler` the SDK's `on_message` consumes.
8//!
9//! The kind-1059 dispatch rule (CLAUDE.md A4): the listen loop tries this v2
10//! path for events on the v2 subscription; DM gift wraps and 3313 Direct Invites
11//! (both `#p=me`) stay on the DM subscription — the author-set here never
12//! includes an identity key, so the two never collide.
13
14use std::collections::HashSet;
15use std::sync::{Arc, LazyLock, Mutex as StdMutex};
16
17use nostr_sdk::prelude::{Client, Event, Filter, Kind, PublicKey, RelayStatus, RelayUrl, SubscriptionId};
18use tokio::sync::mpsc::UnboundedSender;
19use tokio::sync::Mutex;
20
21use super::community::CommunityV2;
22use super::stream;
23use super::{derive, inbound};
24use crate::community::{CommunityId, ConcordProtocol, Epoch};
25use crate::event_handler::InboundEventHandler;
26use crate::state::SessionGuard;
27
28/// The targeted subscription id (streams on desktop).
29static V2_SUB_ID: LazyLock<Mutex<Option<SubscriptionId>>> = LazyLock::new(|| Mutex::new(None));
30/// The pool-wide subscription id (the path that streams on Android).
31static V2_POOLWIDE_SUB_ID: LazyLock<Mutex<Option<SubscriptionId>>> = LazyLock::new(|| Mutex::new(None));
32/// The current author-set (sorted hex) — an unchanged set skips a churny
33/// unsubscribe+resubscribe, exactly like v1's `COMMUNITY_SUB_SET`.
34static V2_SUB_SET: LazyLock<Mutex<Vec<String>>> = LazyLock::new(|| Mutex::new(Vec::new()));
35/// Outer-wrap ids already dispatched, so the handler fires EXACTLY ONCE per
36/// message. The relay pool delivers the same wrap under both the targeted and
37/// pool-wide subs and from every relay independently, so without this a bot's
38/// `on_message` (and its reply) would run several times per message — the v1
39/// community path and the DM path dedup by outer id for the same reason. Cleared
40/// on session swap; coarsely bounded (a message's duplicates all arrive within a
41/// short window, so a recent-set suffices).
42static V2_SEEN_WRAPS: LazyLock<Mutex<HashSet<[u8; 32]>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
43/// Bound on [`V2_SEEN_WRAPS`] before a coarse flush.
44const SEEN_WRAPS_CAP: usize = 8192;
45/// The per-community follow QUEUE. dispatch, boot catch-up, reconnect, and manual
46/// sync all just [`enqueue_follow`] — non-blocking + coalesced. A single spawned
47/// worker ([`spawn_follow_worker`]) drains it and runs one combined rekey+control
48/// follow per community at a time, so two triggers can never concurrently
49/// whole-row-save and clobber each other. This replaces the old gate/rerun/
50/// spawn-vs-await machinery: an enqueue never blocks its caller, and a junk-wrap
51/// flood coalesces to at most one queued + one running follow per community.
52/// Reset on session swap (the worker exits when its `SessionGuard` invalidates or
53/// its channel closes).
54static V2_FOLLOW_TX: LazyLock<StdMutex<Option<UnboundedSender<CommunityId>>>> = LazyLock::new(|| StdMutex::new(None));
55/// Community ids currently queued or processing — coalesces a burst to one follow.
56static V2_FOLLOW_PENDING: LazyLock<StdMutex<HashSet<[u8; 32]>>> = LazyLock::new(|| StdMutex::new(HashSet::new()));
57/// Per-community follow serialization, shared by the queue worker AND the inline
58/// (headless) follow path. The worker-vs-inline CHOICE is a benign race
59/// (`follow_worker_running` is check-then-act; a worker can spawn right after a
60/// `false`), so correctness can't ride on it: whichever path runs, the follow body
61/// executes under this lock and two follows of one community can never interleave
62/// their whole-row saves. Bounded by the held-community count; reset on swap.
63static V2_FOLLOW_LOCKS: LazyLock<StdMutex<std::collections::HashMap<[u8; 32], Arc<Mutex<()>>>>> =
64    LazyLock::new(|| StdMutex::new(std::collections::HashMap::new()));
65
66/// The follow lock for one community (created on first use).
67pub(crate) fn follow_lock(id: &CommunityId) -> Arc<Mutex<()>> {
68    V2_FOLLOW_LOCKS.lock().unwrap().entry(id.0).or_default().clone()
69}
70
71pub async fn subscription_id() -> Option<SubscriptionId> {
72    V2_SUB_ID.lock().await.clone()
73}
74
75pub async fn poolwide_subscription_id() -> Option<SubscriptionId> {
76    V2_POOLWIDE_SUB_ID.lock().await.clone()
77}
78
79/// Clear the v2 realtime state on a session reset (called from `swap_session`
80/// alongside v1's clear), so a stale sub id / author-set can't leak across accounts.
81pub async fn clear() {
82    *V2_SUB_ID.lock().await = None;
83    *V2_POOLWIDE_SUB_ID.lock().await = None;
84    V2_SUB_SET.lock().await.clear();
85    V2_SEEN_WRAPS.lock().await.clear();
86    // Drop the queue sender so the worker's channel closes and it exits (its
87    // SessionGuard also invalidates); the next login spawns a fresh worker.
88    *V2_FOLLOW_TX.lock().unwrap() = None;
89    V2_FOLLOW_PENDING.lock().unwrap().clear();
90    V2_FOLLOW_LOCKS.lock().unwrap().clear();
91    // Account A's stream keys must not keep authenticating (or answering relay
92    // challenges) once account B is live.
93    super::streamauth::clear();
94}
95
96/// Every plane pubkey a set of v2 communities publishes under that
97/// [`inbound::dispatch_wrap`] handles — the subscription author-set. Per
98/// community: the guestbook, the control plane, and each channel's current
99/// Chat-Plane address. Pure + deterministic (deduped, sorted) — the testable core.
100///
101/// The **control plane** rides here so a long-running bot follows metadata +
102/// public-channel edits live ([`super::service::follow_control`] re-folds on a
103/// recognized wrap), and the next-epoch rekey planes ride via [`rekey_authors`]
104/// (each subscribed author has its `dispatch_wrap` arm — never one without the
105/// other).
106pub fn plane_authors(communities: &[CommunityV2]) -> Vec<PublicKey> {
107    let mut out = Vec::new();
108    for c in communities {
109        out.push(derive::guestbook_group_key(&c.community_root, c.id(), c.root_epoch).pk());
110        out.push(control_author(c));
111        // The dissolved plane (CORD-02 §9) — so a mid-session dissolution seals live.
112        out.push(super::derive::dissolved_group_key(c.id()).pk());
113        for ch in &c.channels {
114            // A KEYLESS private channel has no readable chat plane — channel_secret's
115            // root fallback would subscribe the PUBLIC plane for it. Its rekey plane
116            // (below) is still watched, which is how its key arrives.
117            if ch.private && ch.key.is_none() {
118                continue;
119            }
120            let (secret, epoch) = c.channel_secret(ch);
121            out.push(derive::channel_group_key(&secret, &ch.id, epoch).pk());
122        }
123        out.extend(rekey_authors(c));
124    }
125    out.sort_by_key(|p| p.to_hex());
126    out.dedup();
127    out
128}
129
130/// This community's Control Plane address at the current root epoch — the single
131/// source of truth shared by [`plane_authors`] (subscribe) and
132/// [`inbound::dispatch_wrap`] (recognize) so the two can't drift.
133pub(crate) fn control_author(c: &CommunityV2) -> PublicKey {
134    derive::control_group_key(&c.community_root, c.id(), c.root_epoch).pk()
135}
136
137/// The next-epoch rekey plane addresses for a community: the base rotation
138/// (`root_epoch + 1`) and each Private channel's rotation (`channel epoch + 1`),
139/// both under the CURRENT community_root. This is the single source of truth for
140/// which rekey wraps we subscribe AND recognize (`inbound::dispatch_wrap` calls
141/// it), so the two can never drift. A Public channel has no independent rotation
142/// (it rides the base), so only Private channels contribute a channel address.
143pub(crate) fn rekey_authors(c: &CommunityV2) -> Vec<PublicKey> {
144    // saturating: a bundle's epoch isn't covered by the community_id commitment, so
145    // it's attacker-influenced — never let `epoch + 1` overflow (a u64::MAX epoch
146    // just yields a dead address, never a panic).
147    let mut out = vec![derive::base_rekey_group_key(&c.community_root, c.id(), Epoch(c.root_epoch.0.saturating_add(1))).pk()];
148    for ch in &c.channels {
149        if ch.private {
150            out.push(derive::channel_rekey_group_key(&c.community_root, &ch.id, Epoch(ch.epoch.0.saturating_add(1))).pk());
151        }
152    }
153    out
154}
155
156/// Load every locally-held, LIVE **v2** community (dispatching each id by its
157/// stored protocol). The realtime layer folds these into the subscription +
158/// routing, so excluding a DISSOLVED community here is what enforces CORD-02 §9
159/// on the receive side: its chat/control/rekey planes stop being subscribed and
160/// an arriving wrap for it is `NotOurs` (dropped, never honored). Held keys still
161/// open old history through the explicit read paths — this only stops NEW events.
162pub fn load_held_v2() -> Vec<CommunityV2> {
163    let ids = crate::db::community::list_community_ids().unwrap_or_default();
164    ids.iter()
165        .filter(|id| matches!(crate::db::community::community_protocol(id).ok().flatten(), Some(ConcordProtocol::V2)))
166        .filter(|id| !crate::db::community::get_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&id.0)).unwrap_or(false))
167        .filter_map(|id| crate::db::community::load_community_v2(id).ok().flatten())
168        .collect()
169}
170
171/// Refresh the v2 subscription for the held communities: register
172/// `{kinds:[1059,21059], authors:[…]}` on their relays (targeted + pool-wide,
173/// mirroring v1). Idempotent on an unchanged author-set.
174pub async fn refresh_subscription(client: &Client) {
175    // Phase 1, LOCK-FREE: make sure the community relays are added + connected —
176    // the slow part (a connect wait of up to ~6s). Holding the sub locks across
177    // this stalled every concurrent dispatch/refresh behind one caller's connect.
178    {
179        let communities = load_held_v2();
180        let mut relays: Vec<String> = communities.iter().flat_map(|c| c.relays.iter().cloned()).collect();
181        relays.sort();
182        relays.dedup();
183        if !relays.is_empty() {
184            // Community relays ride GOSSIP|PING (warm but excluded from pool-wide DM ops).
185            for r in &relays {
186                let _ = client.pool().add_relay(r.as_str(), crate::community_relay_options()).await;
187            }
188            client.connect().await;
189            // Wait briefly for at least one relay to actually connect (a subscribe
190            // against a still-connecting relay silently fails to register — same
191            // trap as v1).
192            let wanted: Vec<RelayUrl> = relays.iter().filter_map(|r| RelayUrl::parse(r).ok()).collect();
193            for _ in 0..24 {
194                let pool = client.pool().all_relays().await;
195                if wanted.iter().any(|u| pool.get(u).map(|r| r.status() == RelayStatus::Connected).unwrap_or(false)) {
196                    break;
197                }
198                tokio::time::sleep(std::time::Duration::from_millis(250)).await;
199            }
200            // AUTH-gating relays serve the planes only to a stream-authenticated
201            // connection, and a live subscription isn't auto-retried after the gate.
202            // Register every held plane's key + prime the connection auth (a cheap
203            // gated fetch the responder answers) so the subscription below streams.
204            for c in &communities {
205                super::streamauth::register_community(c);
206            }
207            super::streamauth::prime_auth(client, &relays).await;
208        }
209    }
210
211    // Phase 2, LOCKED + bounded (no connect waits): RE-snapshot the held state
212    // INSIDE the sub locks — the follow worker runs concurrently and may have just
213    // adopted a rotation, so the LAST locker must read the freshest persisted
214    // authors. An out-of-lock read lets a stale caller commit an old author-set
215    // over a fresh one and silently mute a rotated community. (A community whose
216    // relays appeared between the phases subscribes now and connects on the next
217    // refresh — the follow that discovers it always triggers one.)
218    let mut sub_guard = V2_SUB_ID.lock().await;
219    let mut set_guard = V2_SUB_SET.lock().await;
220
221    let communities = load_held_v2();
222    let authors = plane_authors(&communities);
223    let mut relays: Vec<String> = communities.iter().flat_map(|c| c.relays.iter().cloned()).collect();
224    relays.sort();
225    relays.dedup();
226
227    let mut new_set: Vec<String> = authors.iter().map(|p| p.to_hex()).collect();
228    new_set.sort();
229
230    // Unchanged-set fast path — but only when BOTH subs actually registered (a
231    // failed pool-wide subscribe would otherwise stay absent until the author-set
232    // changes, and Android streams via the pool-wide path).
233    if sub_guard.is_some() && *set_guard == new_set && (authors.is_empty() || V2_POOLWIDE_SUB_ID.lock().await.is_some()) {
234        return; // the pool re-applies the live subs across reconnects.
235    }
236    if let Some(old) = sub_guard.take() {
237        client.unsubscribe(&old).await;
238    }
239    *set_guard = new_set;
240
241    if authors.is_empty() {
242        if let Some(old_pw) = V2_POOLWIDE_SUB_ID.lock().await.take() {
243            client.unsubscribe(&old_pw).await;
244        }
245        return;
246    }
247
248    let filter = Filter::new()
249        .kinds([Kind::Custom(stream::KIND_WRAP), Kind::Custom(stream::KIND_WRAP_EPHEMERAL)])
250        .authors(authors)
251        .limit(0);
252
253    {
254        let mut pw = V2_POOLWIDE_SUB_ID.lock().await;
255        if let Some(old) = pw.take() {
256            client.unsubscribe(&old).await;
257        }
258        if let Ok(out) = client.subscribe(filter.clone(), None).await {
259            *pw = Some(out.val);
260        }
261    }
262    if let Ok(out) = client.subscribe_to(relays.iter().cloned(), filter, None).await {
263        *sub_guard = Some(out.val);
264    }
265}
266
267/// Re-send the CURRENT v2 subscriptions (same ids) to ONE relay. An AUTH-gating
268/// relay CLOSEs a sub REQ that raced ahead of the stream AUTHs on a fresh
269/// connection, and it never re-challenges once the connection is authenticated —
270/// so the moment the streams finish authenticating is exactly when the subs must
271/// be re-sent. nostr-sdk re-sends them only when ITS OWN auth completes (it
272/// can't see ours), which usually wins by socket ordering; this makes the heal
273/// deterministic and independent of that internal. Same-id REQs are idempotent.
274pub(crate) async fn resubscribe_relay(client: &Client, relay: &RelayUrl) {
275    let targeted = V2_SUB_ID.lock().await.clone();
276    let poolwide = V2_POOLWIDE_SUB_ID.lock().await.clone();
277    if targeted.is_none() && poolwide.is_none() {
278        return; // nothing subscribed yet — the first refresh registers on an authed socket.
279    }
280    let communities = load_held_v2();
281    let authors = plane_authors(&communities);
282    if authors.is_empty() {
283        return;
284    }
285    let filter = Filter::new()
286        .kinds([Kind::Custom(stream::KIND_WRAP), Kind::Custom(stream::KIND_WRAP_EPHEMERAL)])
287        .authors(authors)
288        .limit(0);
289    for id in [targeted, poolwide].into_iter().flatten() {
290        let _ = client.subscribe_with_id_to([relay.clone()], id, filter.clone(), None).await;
291    }
292}
293
294/// Route an arriving v2 wrap: find the held community whose plane it opens under
295/// and fire the matching handler callback (via the shared bridge). Persistence to
296/// the local DB is deferred (bots deliver via the callback; GUI history is v1 for
297/// now). `session` gates against a mid-flight account swap.
298pub async fn dispatch_event(session: &SessionGuard, event: Event, handler: Arc<dyn InboundEventHandler>) {
299    let Some(my_pk) = crate::my_public_key() else {
300        return;
301    };
302    if !session.is_valid() {
303        return;
304    }
305    // Fire EXACTLY ONCE per wrap: the pool re-delivers the same event under both
306    // subs and from every relay. `insert` returns false if already dispatched.
307    {
308        let mut seen = V2_SEEN_WRAPS.lock().await;
309        if !seen.insert(event.id.to_bytes()) {
310            return;
311        }
312        if seen.len() > SEEN_WRAPS_CAP {
313            let keep = event.id.to_bytes();
314            seen.clear();
315            seen.insert(keep);
316        }
317    }
318    let communities = load_held_v2();
319    for c in &communities {
320        match inbound::dispatch_wrap(&event, c, &my_pk, &*handler) {
321            inbound::DispatchedV2::NotOurs => continue,
322            // A control OR a rekey wrap: just enqueue a follow for this community.
323            // Non-blocking + coalesced — the single follow worker serializes control
324            // and rekey per community (no concurrent whole-row clobber) off this hot
325            // path, so a junk-wrap flood can't head-of-line-block the notification loop.
326            inbound::DispatchedV2::Control { .. } | inbound::DispatchedV2::Rekey { .. } => {
327                enqueue_follow(c.id());
328                return;
329            }
330            inbound::DispatchedV2::Dissolved { community_id } => {
331                // Death wins (CORD-02 §9): seal read-only + surface the grave, ONCE
332                // (a re-wrapped tombstone with a fresh outer id must not re-fire the
333                // handler). The next load_held_v2 excludes it, so its planes also
334                // stop being subscribed + routed.
335                if crate::db::community::set_community_dissolved(&community_id).unwrap_or(false) {
336                    handler.on_community_dissolved(&community_id);
337                    if let Some(client) = crate::state::nostr_client() {
338                        refresh_subscription(&client).await;
339                    }
340                }
341                return;
342            }
343            // A chat event, opened but NOT yet applied: persist first (dedup by inner
344            // id + the author-scoped edit/delete checks), then fire the callback from
345            // the outcome — v1's exact model. A re-wrapped duplicate (any keyholder
346            // can re-seal a signed rumor into a fresh 1059), the relay echo of our
347            // own send, or a forged edit/delete yields no outcome and re-fires
348            // nothing.
349            inbound::DispatchedV2::Chat { channel_id, event } => {
350                if !session.is_valid() {
351                    return;
352                }
353                match inbound::persist_chat_event(&event, &channel_id, &my_pk, session).await {
354                    Some(inbound::ChatPersist::New(message)) => handler.on_community_message(&channel_id, &message, true),
355                    // A reaction or an edit: the folded TARGET row (its id is the
356                    // target's) — the same payload v1 hands this callback.
357                    Some(inbound::ChatPersist::Updated { message, .. }) => handler.on_community_update(&channel_id, &message.id, &message),
358                    // An un-react re-renders the PARENT (its chips changed) — the
359                    // same surface a landed reaction drives.
360                    Some(inbound::ChatPersist::ReactionRemoved { message, .. }) => handler.on_community_update(&channel_id, &message.id, &message),
361                    Some(inbound::ChatPersist::Removed(target_id)) => handler.on_community_removed(&channel_id, &target_id),
362                    None => {}
363                }
364                return;
365            }
366            inbound::DispatchedV2::Presence { .. } => {
367                // Live membership motion: fold it into the persisted Guestbook so
368                // the memberlist stays a local read (the presence callback already
369                // fired inline). Reopen here — the dispatcher stays pure — and
370                // refresh the overview when it lands.
371                if !session.is_valid() {
372                    return;
373                }
374                let gb = derive::guestbook_group_key(&c.community_root, c.id(), c.root_epoch);
375                if let Ok(opened) = super::stream::open_wrap(&event, &gb) {
376                    if let Ok(ev) = super::guestbook::parse_guestbook_event(&opened) {
377                        let changed = super::service::ingest_guestbook_event(c, ev, event.created_at.as_secs()).unwrap_or(false);
378                        if changed && session.is_valid() {
379                            handler.on_community_refreshed(&crate::simd::hex::bytes_to_hex_32(&c.id().0));
380                        }
381                    }
382                }
383                return;
384            }
385            _ => return, // typing (and non-surfaced guestbook kinds) handled inline by the dispatcher.
386        }
387    }
388}
389
390/// Whether a live follow worker is draining the queue (a `listen()` is running).
391/// Headless callers use this to run a follow inline instead of enqueueing into
392/// the void.
393pub fn follow_worker_running() -> bool {
394    V2_FOLLOW_TX.lock().unwrap().as_ref().map(|tx| !tx.is_closed()).unwrap_or(false)
395}
396
397/// Queue a follow for `id` — NON-BLOCKING + coalesced. A burst (or a junk-wrap
398/// flood) collapses to at most one queued + one running follow per community. A
399/// no-op if no worker is running (no live `listen()`). Callers: dispatch,
400/// boot/reconnect catch-up, manual sync — none of them block or touch a lock for
401/// longer than the enqueue.
402pub fn enqueue_follow(id: &CommunityId) {
403    let mut pending = V2_FOLLOW_PENDING.lock().unwrap();
404    if !pending.insert(id.0) {
405        return; // already queued or processing — coalesce.
406    }
407    match V2_FOLLOW_TX.lock().unwrap().as_ref() {
408        Some(tx) if tx.send(*id).is_ok() => {}
409        _ => {
410            pending.remove(&id.0); // no worker / channel closed — nothing queued.
411        }
412    }
413}
414
415/// Spawn the single follow worker for this session. Installs the queue sender and
416/// drains it, running one combined follow per community at a time. Replacing the
417/// sender (a re-`listen()`) or [`clear`] (a swap) closes the old channel so the old
418/// worker exits; the captured `SessionGuard` also stops it. Idempotent per session.
419pub fn spawn_follow_worker(handler: Arc<dyn InboundEventHandler>) {
420    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<CommunityId>();
421    *V2_FOLLOW_TX.lock().unwrap() = Some(tx);
422    V2_FOLLOW_PENDING.lock().unwrap().clear();
423    let session = SessionGuard::capture();
424    tokio::spawn(async move {
425        while let Some(id) = rx.recv().await {
426            if !session.is_valid() {
427                break;
428            }
429            // Remove from pending BEFORE running, so a trigger arriving DURING the
430            // follow re-enqueues (and is processed after) rather than being lost.
431            V2_FOLLOW_PENDING.lock().unwrap().remove(&id.0);
432            follow_community(&session, &id, &*handler).await;
433        }
434    });
435}
436
437/// One combined rekey-then-control follow for a community, each pass against the
438/// FRESHLY-RELOADED persisted state (never a stale clone, so the two planes can't
439/// lose each other's writes). Rekey runs first: a base adopt moves the control
440/// address, and a self-removal tears the community down (skipping control). No-op
441/// without a live client — unit tests drive `service::follow_control` /
442/// `follow_rekeys` directly.
443async fn follow_community(session: &SessionGuard, id: &CommunityId, handler: &dyn InboundEventHandler) {
444    let Some(client) = crate::state::nostr_client() else {
445        return;
446    };
447    // Serialize against an inline (headless) follow of the same community — the
448    // queue only serializes triggers routed THROUGH it.
449    let lock = follow_lock(id);
450    let _guard = lock.lock().await;
451    let community_id = crate::simd::hex::bytes_to_hex_32(&id.0);
452    let transport = crate::community::transport::LiveTransport::with_timeout(std::time::Duration::from_secs(12));
453
454    // Rekey first (fresh DB state).
455    let Ok(Some(current)) = crate::db::community::load_community_v2(id) else {
456        return; // community gone (left / removed).
457    };
458    match super::service::follow_rekeys(&transport, &current, session).await {
459        // A tombstone surfaced during catch-up (an offline member learning of a
460        // death) — the flag is set; seal + surface, and stop following.
461        Ok(follow) if follow.dissolved => {
462            if !session.is_valid() {
463                return;
464            }
465            handler.on_community_dissolved(&community_id);
466            return;
467        }
468        Ok(follow) if follow.self_removed => {
469            if !session.is_valid() {
470                return;
471            }
472            let _ = crate::db::community::delete_community(&community_id);
473            refresh_subscription(&client).await;
474            handler.on_community_self_removed(&community_id);
475            return;
476        }
477        Ok(follow) if follow.updated.is_some() => {
478            if !session.is_valid() {
479                return;
480            }
481            refresh_subscription(&client).await;
482            handler.on_community_refreshed(&community_id);
483        }
484        Ok(_) => {}
485        Err(_) => return,
486    }
487
488    // Control second, on the (possibly new-root) freshly-reloaded state.
489    let Ok(Some(current)) = crate::db::community::load_community_v2(id) else {
490        return;
491    };
492    if let Ok(Some(_)) = super::service::follow_control(&transport, &current, session).await {
493        if !session.is_valid() {
494            return;
495        }
496        refresh_subscription(&client).await;
497        handler.on_community_refreshed(&community_id);
498        // A control change can reveal rekey work that predates it — a just-announced
499        // private channel's key crate is already sitting on its rekey plane (the key
500        // ships BEFORE the vsk-2), and this pass's rekey walk ran before the channel
501        // existed. Queue one more pass; it coalesces and converges (an unchanged
502        // control fold doesn't re-queue).
503        enqueue_follow(id);
504    }
505
506    // Guestbook third: catch the membership store up from its cursor. Boot and
507    // reconnect land here through this same queue, so the memberlist is a local
508    // read by the time any panel asks — and every join/leave the catch-up folds
509    // surfaces as a presence line (real-rumor-id keyed, so a line each path also
510    // saw live inserts exactly once).
511    let Ok(Some(current)) = crate::db::community::load_community_v2(id) else {
512        return;
513    };
514    if let Ok(fresh) = super::service::sync_guestbook(&transport, &current, session).await {
515        if fresh.is_empty() || !session.is_valid() {
516            return;
517        }
518        surface_presence(&current, &fresh, handler);
519        handler.on_community_refreshed(&community_id);
520    }
521}
522
523/// Fire the presence-line surface for freshly-folded guestbook events — the
524/// catch-up twin of the live dispatch (same handler, same real-id dedup key,
525/// same banned-author drop). Kicks/snapshots shape the memberlist, not the feed.
526fn surface_presence(
527    community: &CommunityV2,
528    fresh: &[super::guestbook::GuestbookEvent],
529    handler: &dyn InboundEventHandler,
530) {
531    use super::guestbook::GuestbookEntry;
532    use nostr_sdk::prelude::ToBech32;
533    let Some(primary) = community.primary_channel() else {
534        return;
535    };
536    let chat_id = crate::simd::hex::bytes_to_hex_32(&primary.id.0);
537    let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
538    let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
539    for ev in fresh {
540        let (member, joined, at_ms, invited_by) = match &ev.entry {
541            GuestbookEntry::Join { member, at_ms, invited_by } => (member, true, *at_ms, invited_by.clone()),
542            GuestbookEntry::Leave { member, at_ms } => (member, false, *at_ms, None),
543            GuestbookEntry::Kick { .. } | GuestbookEntry::Snapshot { .. } => continue,
544        };
545        if banned.contains(&member.to_hex()) {
546            continue;
547        }
548        let Ok(npub) = member.to_bech32() else { continue };
549        let event_id = crate::simd::hex::bytes_to_hex_32(&ev.rumor_id);
550        let (by, label) = match &invited_by {
551            Some((c, l)) => (Some(c.as_str()), Some(l.as_str())),
552            None => (None, None),
553        };
554        handler.on_community_presence(&chat_id, &npub, joined, &event_id, at_ms / 1000, by, label);
555    }
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561    use super::super::control::{genesis, CommunityMetadata};
562    use crate::community::Epoch;
563    use nostr_sdk::prelude::Keys;
564
565    fn a_community(name: &str) -> CommunityV2 {
566        let owner = Keys::generate();
567        let g = genesis(&owner, CommunityMetadata { name: name.into(), ..Default::default() }, 1_000).unwrap();
568        CommunityV2::from_genesis(&g, name, None, vec!["wss://r".into()], 0)
569    }
570
571    #[test]
572    fn plane_authors_covers_the_dispatched_planes_only() {
573        let c = a_community("A");
574        let authors = plane_authors(std::slice::from_ref(&c));
575
576        // Subscribed: the guestbook, the control plane, and the one public channel
577        // (the planes dispatch_wrap handles). Exactly those three.
578        let gb = derive::guestbook_group_key(&c.community_root, c.id(), c.root_epoch).pk();
579        let control = derive::control_group_key(&c.community_root, c.id(), c.root_epoch).pk();
580        let general = {
581            let (s, e) = c.channel_secret(&c.channels[0]);
582            derive::channel_group_key(&s, &c.channels[0].id, e).pk()
583        };
584        // Plus the next base-rekey address (rekey-follow). A public channel has no
585        // independent rotation, so #general contributes no channel-rekey address.
586        let next_base = derive::base_rekey_group_key(&c.community_root, c.id(), Epoch(1)).pk();
587        // Plus the dissolved plane (CORD-02 §9), so a live dissolution is detected.
588        let dissolved = derive::dissolved_group_key(c.id()).pk();
589        assert!(
590            authors.contains(&gb)
591                && authors.contains(&control)
592                && authors.contains(&general)
593                && authors.contains(&next_base)
594                && authors.contains(&dissolved)
595        );
596        assert_eq!(authors.len(), 5, "guestbook + control + dissolved + chat + base-rekey planes are subscribed");
597    }
598
599    #[test]
600    fn plane_authors_is_deterministic_deduped_and_multi_community() {
601        let a = a_community("A");
602        let b = a_community("B");
603        let one = plane_authors(std::slice::from_ref(&a));
604        // Re-running over the same community is byte-identical (deterministic).
605        assert_eq!(plane_authors(std::slice::from_ref(&a)), one);
606        // Two distinct communities' planes are all present, none dropped.
607        let two = plane_authors(&[a.clone(), b.clone()]);
608        assert_eq!(two.len(), one.len() * 2);
609        // Order-independent: reversing the input yields the identical sorted set.
610        assert_eq!(plane_authors(&[b, a]), two);
611    }
612
613    #[tokio::test]
614    async fn dispatch_event_routes_a_v2_message_to_the_handler() {
615        use crate::community::transport::memory::MemoryRelay;
616        use crate::community::transport::{Query, Transport};
617        use crate::types::Message;
618        use std::sync::Mutex as StdMutex;
619
620        #[derive(Default)]
621        struct Recorder {
622            got: StdMutex<Vec<(String, String)>>,
623        }
624        impl InboundEventHandler for Recorder {
625            fn on_community_message(&self, chat_id: &str, msg: &Message, _new: bool) {
626                self.got.lock().unwrap().push((chat_id.to_string(), msg.content.clone()));
627            }
628        }
629
630        // Offline DB + identity.
631        let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
632        crate::db::close_database();
633        crate::db::clear_id_caches();
634        let tmp = tempfile::tempdir().unwrap();
635        let acct = {
636            const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
637            let mut s = String::from("npub1");
638            for i in 0..58 {
639                s.push(B[(i * 5 + 1) % 32] as char);
640            }
641            s
642        };
643        std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
644        crate::db::set_app_data_dir(tmp.path().to_path_buf());
645        crate::db::set_current_account(acct.clone()).unwrap();
646        crate::db::init_database(&acct).unwrap();
647        let _ = crate::state::take_nostr_client();
648        let me = Keys::generate();
649        crate::state::MY_SECRET_KEY.store_from_keys(&me, &[]);
650        crate::state::set_my_public_key(me.public_key());
651
652        // Create a v2 community (persisted), then ANOTHER member (holds the root)
653        // posts — the incoming case a live sub delivers (an OWN send is echoed at
654        // send time, so its relay copy correctly dedups instead of firing).
655        let relay = MemoryRelay::new();
656        let community = super::super::service::create_community(&relay, "Live", vec!["wss://r".into()], None).await.unwrap();
657        let general = community.channels[0].id;
658        let member = Keys::generate();
659        let group = derive::channel_group_key(&community.community_root, &general, community.root_epoch);
660        let rumor = super::super::chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "live ping", None, &[], vec![], 5_000);
661        let (wrap, _) = super::super::chat::seal_chat_rumor(&rumor, &group, &member, nostr_sdk::prelude::Timestamp::from_secs(5), false).unwrap();
662        let _ = relay.publish(&wrap, &community.relays).await;
663        let q = Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], ..Default::default() };
664        let wrap = relay.fetch(&q, &community.relays).await.unwrap().into_iter().find(|w| w.pubkey == group.pk()).unwrap();
665
666        // The realtime dispatch (loading held v2 communities from the DB) routes it.
667        // Dispatch the SAME wrap TWICE — modelling the pool re-delivering it under
668        // the targeted + pool-wide subs (and from multiple relays). The handler
669        // must fire EXACTLY ONCE (no duplicate bot replies) — and only AFTER the
670        // persist outcome (the callbacks-from-persist model).
671        let rec = Arc::new(Recorder::default());
672        let session = SessionGuard::capture();
673        crate::community::v2::realtime::clear().await; // fresh seen-set for the test
674        dispatch_event(&session, wrap.clone(), rec.clone()).await;
675        dispatch_event(&session, wrap, rec.clone()).await;
676
677        let got = rec.got.lock().unwrap();
678        assert_eq!(got.len(), 1, "a re-delivered wrap fires the handler exactly once");
679        assert_eq!(got[0].1, "live ping");
680        assert_eq!(got[0].0, crate::simd::hex::bytes_to_hex_32(&general.0));
681    }
682
683    #[tokio::test]
684    async fn follow_queue_coalesces_a_burst_and_re_enqueues_after_processing() {
685        // Install a test channel in place of the worker's, so we can observe what the
686        // queue delivers without a live client.
687        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<CommunityId>();
688        *V2_FOLLOW_TX.lock().unwrap() = Some(tx);
689        V2_FOLLOW_PENDING.lock().unwrap().clear();
690
691        let id = CommunityId([0x11; 32]);
692        // A burst for one community collapses to a SINGLE queued follow (coalesced).
693        enqueue_follow(&id);
694        enqueue_follow(&id);
695        enqueue_follow(&id);
696        assert_eq!(rx.recv().await, Some(id), "first trigger queues a follow");
697        assert!(rx.try_recv().is_err(), "the burst coalesced to exactly one");
698
699        // The worker removes it from pending before running; a trigger AFTER that
700        // re-queues (so a change during a follow isn't lost).
701        V2_FOLLOW_PENDING.lock().unwrap().remove(&id.0);
702        enqueue_follow(&id);
703        assert_eq!(rx.recv().await, Some(id), "a trigger after processing re-queues");
704
705        // A different community is independent (not coalesced against the first).
706        let id2 = CommunityId([0x22; 32]);
707        enqueue_follow(&id2);
708        assert_eq!(rx.recv().await, Some(id2));
709
710        *V2_FOLLOW_TX.lock().unwrap() = None;
711        V2_FOLLOW_PENDING.lock().unwrap().clear();
712    }
713
714    #[tokio::test]
715    async fn a_dissolved_community_honors_no_new_events_and_fires_death_once() {
716        use super::super::service;
717        use crate::community::transport::memory::MemoryRelay;
718        use crate::community::transport::Transport;
719        use crate::types::Message;
720        use std::sync::Mutex as StdMutex;
721
722        #[derive(Default)]
723        struct Recorder {
724            messages: StdMutex<Vec<String>>,
725            deaths: StdMutex<Vec<String>>,
726        }
727        impl InboundEventHandler for Recorder {
728            fn on_community_message(&self, _chat: &str, msg: &Message, _new: bool) {
729                self.messages.lock().unwrap().push(msg.content.clone());
730            }
731            fn on_community_dissolved(&self, community_id: &str) {
732                self.deaths.lock().unwrap().push(community_id.to_string());
733            }
734        }
735
736        let _g = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
737        crate::db::close_database();
738        crate::db::clear_id_caches();
739        let tmp = tempfile::tempdir().unwrap();
740        let acct = {
741            const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
742            let mut s = String::from("npub1");
743            for i in 0..58 {
744                s.push(B[(i * 3 + 2) % 32] as char);
745            }
746            s
747        };
748        std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
749        crate::db::set_app_data_dir(tmp.path().to_path_buf());
750        crate::db::set_current_account(acct.clone()).unwrap();
751        crate::db::init_database(&acct).unwrap();
752        let _ = crate::state::take_nostr_client();
753        let me = Keys::generate();
754        crate::state::MY_SECRET_KEY.store_from_keys(&me, &[]);
755        crate::state::set_my_public_key(me.public_key());
756
757        let relay = MemoryRelay::new();
758        let community = service::create_community(&relay, "Doomed", vec!["wss://r".into()], None).await.unwrap();
759        let general = community.channels[0].id;
760
761        // The owner's tombstone arrives as a MEMBER sees it (local flag still 0 —
762        // build + publish it directly rather than via dissolve_community, which
763        // would seal our own DB first and make it the owner-published case). `me`
764        // is the owner, so the seal verifies.
765        let rumor = super::super::dissolution::dissolved_tombstone_rumor(me.public_key(), community.id(), 8_000);
766        let tombstone = super::super::dissolution::seal_dissolved(&rumor, community.id(), &me, nostr_sdk::prelude::Timestamp::from_secs(8_000)).unwrap();
767        let _ = relay.publish(&tombstone, &community.relays).await;
768        assert!(!crate::db::community::get_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap(), "not yet locally sealed");
769
770        let rec = Arc::new(Recorder::default());
771        let session = SessionGuard::capture();
772        clear().await;
773        // Fire the SAME tombstone twice AND a fresh re-wrap of its verified seal
774        // (distinct outer id) — death must be announced exactly once.
775        dispatch_event(&session, tombstone.clone(), rec.clone()).await;
776        dispatch_event(&session, tombstone, rec.clone()).await;
777        assert!(crate::db::community::get_community_dissolved(&crate::simd::hex::bytes_to_hex_32(&community.id().0)).unwrap());
778
779        // A member posts a fresh message to #general AFTER the tombstone. It must
780        // not be honored (the community is excluded from load_held_v2, so its
781        // plane is NotOurs).
782        let member = Keys::generate();
783        let cgroup = derive::channel_group_key(&community.community_root, &general, community.root_epoch);
784        let rumor = super::super::chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "into the grave", None, &[], vec![], 9_000);
785        let (mw, _) = super::super::chat::seal_chat_rumor(&rumor, &cgroup, &member, nostr_sdk::prelude::Timestamp::from_secs(9), false).unwrap();
786        dispatch_event(&session, mw, rec.clone()).await;
787
788        assert_eq!(rec.deaths.lock().unwrap().len(), 1, "death is announced exactly once");
789        assert!(rec.messages.lock().unwrap().is_empty(), "a post-tombstone message is never honored (CORD-02 §9)");
790    }
791
792    #[test]
793    fn a_private_channel_subscribes_to_its_own_chat_plane() {
794        let mut c = a_community("Priv");
795        c.channels.push(super::super::community::ChannelV2 {
796            id: crate::community::ChannelId([0x33; 32]),
797            name: "mods".into(),
798            private: true,
799            key: Some([0x44; 32]),
800            epoch: Epoch(1),
801            voice: None,
802            meta_custom: None,
803            meta_extra: Default::default(),
804        });
805        let authors = plane_authors(std::slice::from_ref(&c));
806        // A private channel is read under its OWN key/epoch (not the root).
807        let priv_chat = derive::channel_group_key(&[0x44; 32], &c.channels[1].id, Epoch(1)).pk();
808        assert!(authors.contains(&priv_chat), "a private channel subscribes to its own chat plane");
809        // Its next-rekey address IS subscribed (rekey-follow), keyed by the current
810        // root at the channel's next epoch — so a rotation is delivered.
811        let next_rekey = derive::channel_rekey_group_key(&c.community_root, &c.channels[1].id, Epoch(2)).pk();
812        assert!(authors.contains(&next_rekey), "a private channel's next rekey plane is subscribed");
813    }
814}