Skip to main content

vector_core/community/
realtime.rs

1//! Realtime Community (Concord) subscription, routing, dispatch, and control-follow.
2//!
3//! The per-event cryptographic engine ([`inbound::process_incoming`]) already lives in core;
4//! this module is the realtime *plumbing* around it — the relay subscription, the pseudonym→channel
5//! route maps, the dispatch of typed [`inbound::IncomingEvent`]s to an [`InboundEventHandler`], and
6//! the control/rekey realtime-follow. It is consumed by [`crate::VectorCore::listen`] so headless
7//! clients (SDK, CLI, bots) get realtime Community delivery through the same handler as DMs.
8
9use std::collections::{HashMap, HashSet};
10use std::sync::{LazyLock, Mutex as StdMutex};
11use std::sync::Arc;
12use std::time::Duration;
13
14use nostr_sdk::prelude::*;
15use tokio::sync::Mutex;
16
17use crate::community::{derive, inbound, roster, service, Channel, CommunityId, Epoch};
18use crate::community::transport::LiveTransport;
19use crate::event_handler::InboundEventHandler;
20use crate::stored_event::event_kind;
21use crate::ClientRelayExt;
22
23/// Realtime channel-follow retry budget: a re-founding publishes the channel rekey under the NEW
24/// root right after the base rekey, so the base-3303-triggered follow can race its propagation.
25const CHANNEL_FOLLOW_MAX_ATTEMPTS: usize = 5;
26const CHANNEL_FOLLOW_BACKOFF_MS: u64 = 700;
27
28/// Current Community subscription id — single subscription scoped to the epoch pseudonyms of
29/// every channel we hold; refreshed on join/leave/rekey.
30static COMMUNITY_SUB_ID: LazyLock<Mutex<Option<SubscriptionId>>> = LazyLock::new(|| Mutex::new(None));
31
32/// Sorted pseudonym set of the CURRENTLY live Community subscription. Lets `refresh_subscription` skip
33/// a redundant unsubscribe+resubscribe when nothing changed (e.g. a metadata/avatar edit, which fires
34/// a control re-fold but doesn't alter the pseudonym set). The relay pool auto-re-applies the existing
35/// subscription on every reconnect, so leaving it in place is strictly more reliable than rebuilding it
36/// (a rebuild that lands mid-reconnect silently fails to register and kills realtime delivery).
37static COMMUNITY_SUB_SET: LazyLock<Mutex<Vec<String>>> = LazyLock::new(|| Mutex::new(Vec::new()));
38
39/// Pool-wide `subscribe` of the community filter, opened alongside the targeted `subscribe_to`. On
40/// Android the targeted sub registers but never streams; the pool-wide one does (it rides the same
41/// auto-managed path the DM sub uses). On desktop the targeted sub streams. We keep BOTH so every
42/// platform gets live delivery; `process_incoming` dedups by outer-event id, so an event seen on both
43/// folds exactly once.
44static COMMUNITY_POOLWIDE_SUB_ID: LazyLock<Mutex<Option<SubscriptionId>>> = LazyLock::new(|| Mutex::new(None));
45
46/// `z` pseudonym (hex) → the Channel it belongs to. Lets the notification loop open an arriving
47/// event against the right channel key (and read its `banned` set for the inbound drop-filter).
48static COMMUNITY_ROUTES: LazyLock<Mutex<HashMap<String, Channel>>> =
49    LazyLock::new(|| Mutex::new(HashMap::new()));
50
51/// Control-plane `z` pseudonym (hex) → community id (hex). Routes a CONTROL edition (3308) or a
52/// base/channel REKEY coordinate (3303) to a realtime control refresh of that community.
53static CONTROL_ROUTES: LazyLock<Mutex<HashMap<String, String>>> =
54    LazyLock::new(|| Mutex::new(HashMap::new()));
55
56/// Per-community in-flight guard for [`refresh_control`] — one concurrent fold per community.
57static REFRESH_CONTROL_INFLIGHT: LazyLock<StdMutex<HashSet<String>>> =
58    LazyLock::new(|| StdMutex::new(HashSet::new()));
59
60/// The subscription id of the live Community subscription, if any. Lets a notification loop test
61/// whether an arriving event belongs to the Community sub.
62pub async fn subscription_id() -> Option<SubscriptionId> {
63    COMMUNITY_SUB_ID.lock().await.clone()
64}
65
66/// The pool-wide subscription id (the path that streams on Android, where the
67/// targeted `subscribe_to` registers but never delivers). The listen loop must OR
68/// this with [`subscription_id`], else events arriving under the pool-wide sub
69/// match no branch and are silently dropped.
70pub async fn poolwide_subscription_id() -> Option<SubscriptionId> {
71    COMMUNITY_POOLWIDE_SUB_ID.lock().await.clone()
72}
73
74/// Clear all realtime route/subscription state. Call from `swap_session` so a swapped-in account
75/// can't read the prior account's channel keys / banned sets.
76pub async fn clear() {
77    *COMMUNITY_SUB_ID.lock().await = None;
78    *COMMUNITY_POOLWIDE_SUB_ID.lock().await = None;
79    COMMUNITY_SUB_SET.lock().await.clear();
80    COMMUNITY_ROUTES.lock().await.clear();
81    CONTROL_ROUTES.lock().await.clear();
82    REFRESH_CONTROL_INFLIGHT.lock().unwrap_or_else(|e| e.into_inner()).clear();
83    super::migration::clear_drive_inflight();
84}
85
86/// Rebuild ONLY the in-memory route maps from the persisted communities, WITHOUT touching the live
87/// relay subscription. Refreshes each cached `Channel` (incl. its `banned` set) so a received
88/// ban/unban takes live effect without a full resubscribe. Returns the pseudonyms + relays a caller
89/// also resubscribing needs.
90pub async fn rebuild_routes() -> (Vec<String>, HashSet<String>) {
91    let mut routes: HashMap<String, Channel> = HashMap::new();
92    let mut control_routes: HashMap<String, String> = HashMap::new();
93    let mut pseudonyms: Vec<String> = Vec::new();
94    let mut relays: HashSet<String> = HashSet::new();
95
96    if let Ok(ids) = crate::db::community::list_community_ids() {
97        for id in ids {
98            if let Ok(Some(community)) = crate::db::community::load_community(&id) {
99                for r in &community.relays {
100                    relays.insert(r.clone());
101                }
102                for ch in &community.channels {
103                    // Subscribe to EVERY held epoch pseudonym (not just the head) so a straggler posting
104                    // under a retained older epoch still arrives in realtime; the inbound router opens each
105                    // against the channel's full keyset.
106                    for (epoch, key) in ch.read_epoch_keys() {
107                        let pseudonym = derive::channel_pseudonym(&key, &ch.id, epoch).to_hex();
108                        pseudonyms.push(pseudonym.clone());
109                        routes.insert(pseudonym, ch.clone());
110                    }
111                    // The NEXT channel-rekey coordinate (3303) under the CURRENT server root — follow a
112                    // channel rotation event-driven the instant it lands.
113                    let next_chan = derive::rekey_pseudonym(
114                        &community.server_root_key, &ch.id, Epoch(ch.epoch.0 + 1),
115                    ).to_hex();
116                    pseudonyms.push(next_chan.clone());
117                    control_routes.insert(next_chan, community.id.to_hex());
118                }
119                // Control-plane pseudonym at the current server-root epoch (banlist/roles/metadata/invites).
120                let ctrl = roster::control_pseudonym(
121                    &community.server_root_key, &community.id, community.server_root_epoch,
122                );
123                pseudonyms.push(ctrl.clone());
124                control_routes.insert(ctrl, community.id.to_hex());
125                // The NEXT base re-founding coordinate (3303) — follow a privatize / private-ban in realtime.
126                let next_base = derive::base_rekey_pseudonym(
127                    &community.server_root_key, &community.id,
128                    Epoch(community.server_root_epoch.0 + 1),
129                ).to_hex();
130                pseudonyms.push(next_base.clone());
131                control_routes.insert(next_base, community.id.to_hex());
132            }
133        }
134    }
135
136    *COMMUNITY_ROUTES.lock().await = routes;
137    *CONTROL_ROUTES.lock().await = control_routes;
138    (pseudonyms, relays)
139}
140
141/// The coalesced control-probe coordinate set: every held v1 community's
142/// control pseudonym + next-base-rekey + next-channel-rekey `z` coordinate,
143/// mapped back to its community id. A single change-detector fetch over these
144/// (kinds 3308/3303) tells the boot sweep which communities actually have new
145/// control/rotation editions, so the rest skip the per-community catch-up chain.
146/// Returns `(coordinates, coordinate → community_id_hex, relay_union)`.
147pub async fn control_probe_coordinates() -> (Vec<String>, HashMap<String, String>, HashSet<String>) {
148    let mut coords: Vec<String> = Vec::new();
149    let mut map: HashMap<String, String> = HashMap::new();
150    let mut relays: HashSet<String> = HashSet::new();
151    let Ok(ids) = crate::db::community::list_community_ids() else {
152        return (coords, map, relays);
153    };
154    for id in ids {
155        // v1 only — v2 control/rekey planes are author-addressed giftwraps (a
156        // separate, auth-gated probe; see B1b).
157        if matches!(
158            crate::db::community::community_protocol(&id).ok().flatten(),
159            Some(crate::community::ConcordProtocol::V2)
160        ) {
161            continue;
162        }
163        let Ok(Some(community)) = crate::db::community::load_community(&id) else {
164            continue;
165        };
166        let cid = community.id.to_hex();
167        for r in &community.relays {
168            relays.insert(r.clone());
169        }
170        let mut add = |coord: String| {
171            if let std::collections::hash_map::Entry::Vacant(slot) = map.entry(coord) {
172                coords.push(slot.key().clone());
173                slot.insert(cid.clone());
174            }
175        };
176        add(roster::control_pseudonym(&community.server_root_key, &community.id, community.server_root_epoch));
177        add(derive::base_rekey_pseudonym(&community.server_root_key, &community.id, Epoch(community.server_root_epoch.0 + 1)).to_hex());
178        for ch in &community.channels {
179            add(derive::rekey_pseudonym(&community.server_root_key, &ch.id, Epoch(ch.epoch.0 + 1)).to_hex());
180        }
181    }
182    (coords, map, relays)
183}
184
185/// (Re)build the Community subscription: rebuild the route maps, then open TWO subscriptions over the
186/// same filter — a targeted `subscribe_to` (streams on desktop) and a pool-wide `subscribe` (streams on
187/// Android). `process_incoming` dedups by outer-event id, so overlap folds exactly once.
188/// Re-subscribe unconditionally — see the v2 twin for why.
189///
190/// The idempotent path below keeps an existing sub when the pseudonym set has not
191/// moved, trusting the pool to re-apply it across reconnects. A relay that sheds a
192/// REQ under the boot sweep's flood never reconnects, so nothing re-applies it and
193/// our own state still claims it is live. Forgetting the id first is what makes the
194/// post-sweep re-assert actually assert.
195pub async fn force_refresh_subscription(client: &Client) {
196    {
197        let mut sub_guard = COMMUNITY_SUB_ID.lock().await;
198        if let Some(old) = sub_guard.take() {
199            let _ = client.unsubscribe(&old).await;
200        }
201        COMMUNITY_SUB_SET.lock().await.clear();
202    }
203    refresh_subscription(client).await;
204}
205
206pub async fn refresh_subscription(client: &Client) {
207    let (pseudonyms, relays) = rebuild_routes().await;
208
209    // Hold COMMUNITY_SUB_ID across the unsubscribe+subscribe ON PURPOSE: it serializes concurrent
210    // refreshers (Monitor, health-probe reconnect, refresh_control, accept_invite) so they can't
211    // race into a duplicate subscription. Narrowing this lock would reintroduce that double-sub race.
212    let mut new_set = pseudonyms.clone();
213    new_set.sort();
214
215    let mut sub_guard = COMMUNITY_SUB_ID.lock().await;
216    let mut set_guard = COMMUNITY_SUB_SET.lock().await;
217
218    // Idempotent: unchanged pseudonym set + a live sub → keep it (the pool auto-re-applies it across
219    // reconnects, verified). Rebuilding would be pure churn and a rebuild landing mid-reconnect silently
220    // fails to register. rebuild_routes() above already refreshed routes/banlist — all a no-change needs.
221    if sub_guard.is_some() && *set_guard == new_set {
222        return;
223    }
224
225    if let Some(old_id) = sub_guard.take() {
226        let _ = client.unsubscribe(&old_id).await;
227    }
228    *set_guard = new_set;
229
230    if pseudonyms.is_empty() {
231        if let Some(old_pw) = COMMUNITY_POOLWIDE_SUB_ID.lock().await.take() {
232            let _ = client.unsubscribe(&old_pw).await;
233        }
234        return;
235    }
236
237    // Community events live on the community's relays, which may differ from the user's DM relays.
238    // Add them GOSSIP|PING (warm, but excluded from pool-wide DM/profile ops) and subscribe by TARGET.
239    for r in &relays {
240        let _ = client.add_managed_relay(r.as_str()).capabilities(crate::community_relay_capabilities()).await;
241    }
242    client.connect().await;
243
244    // Wait (briefly) for at least one community relay to actually CONNECT before subscribing. A
245    // subscribe_to/subscribe issued against a still-connecting relay silently fails to register the
246    // live sub (seen right after the Android bg-sync churn / on create-with-avatar, which fires extra
247    // control re-folds → re-subscribes mid-reconnect). Polling until a socket is live makes the sub
248    // land reliably regardless of churn timing. Dead/slow relays (e.g. a timing-out one) are ignored —
249    // one connected relay is enough to stream.
250    {
251        let wanted: Vec<RelayUrl> = relays.iter().filter_map(|r| RelayUrl::parse(r).ok()).collect();
252        for _ in 0..24 {
253            let pool = client.relays().all().await;
254            let any_live = wanted.iter().any(|u| {
255                pool.get(u).map(|r| r.status() == RelayStatus::Connected).unwrap_or(false)
256            });
257            if any_live {
258                break;
259            }
260            crate::rt::time::sleep(Duration::from_millis(250)).await;
261        }
262    }
263
264    let filter = Filter::new()
265        .kinds([
266            Kind::Custom(event_kind::COMMUNITY_MESSAGE),
267            Kind::Custom(event_kind::COMMUNITY_REACTION),
268            Kind::Custom(event_kind::COMMUNITY_EDIT),
269            Kind::Custom(event_kind::COMMUNITY_DELETE),
270            Kind::Custom(event_kind::COMMUNITY_PRESENCE),
271            Kind::Custom(event_kind::COMMUNITY_KICK),
272            Kind::Custom(event_kind::COMMUNITY_TYPING),
273            Kind::Custom(event_kind::COMMUNITY_WEBXDC),
274            Kind::Custom(event_kind::COMMUNITY_CONTROL),
275            Kind::Custom(event_kind::COMMUNITY_REKEY),
276        ])
277        .custom_tags(SingleLetterTag::LOWERCASE_Z, pseudonyms)
278        // limit(0) suppresses the stored dump at REQ time, but a relay that
279        // ACCEPTS a backfilled old event later pushes it to matching live subs
280        // regardless — the `since` floor excludes those at the relay.
281        .since(Timestamp::from_secs(
282            Timestamp::now().as_secs().saturating_sub(super::REALTIME_FRESH_WINDOW_MS / 1000),
283        ))
284        .limit(0);
285
286    // Pool-wide subscribe — the path that streams on Android (replaces any prior one).
287    {
288        let mut pw = COMMUNITY_POOLWIDE_SUB_ID.lock().await;
289        if let Some(old) = pw.take() {
290            let _ = client.unsubscribe(&old).await;
291        }
292        if let Ok(out) = client.subscribe(filter.clone()).await {
293            *pw = Some(out.value);
294        }
295    }
296    // Targeted subscribe — the path that streams on desktop.
297    if let Ok(output) = client
298        .subscribe(nostr_sdk::prelude::ReqTarget::manual(
299            relays.iter().cloned().map(|u| (u, vec![filter.clone()])),
300        ))
301        .await
302    {
303        *sub_guard = Some(output.value);
304    }
305}
306
307/// Route an arriving Community event: a CONTROL/REKEY edition triggers a realtime control refresh;
308/// any other (message/reaction/edit/delete/presence/typing/webxdc/kick) is opened against its
309/// channel via [`inbound::process_incoming`], persisted, and dispatched to `handler`. `session`
310/// straddles the relay I/O so a mid-flight account swap can't write into the swapped-in account.
311pub async fn dispatch_event(
312    event: Event,
313    handler: Arc<dyn InboundEventHandler>,
314) {
315    crate::db::scoped(async move {
316        let Some(my_pk) = crate::my_public_key() else { return; };
317        let Some(pseudonym) = event.tags.iter().find_map(|t| {
318            let s = t.as_slice();
319            (s.len() >= 2 && s[0] == "z").then(|| s[1].clone())
320        }) else { return; };
321
322        // A CONTROL edition (3308) or base/channel REKEY (3303) → follow the control plane in realtime.
323        let kind = event.kind.as_u16();
324        if kind == event_kind::COMMUNITY_CONTROL || kind == event_kind::COMMUNITY_REKEY {
325            let community_id = CONTROL_ROUTES.lock().await.get(&pseudonym).cloned();
326            if let Some(community_id) = community_id {
327                // Spawn off the loop — the refresh runs several relay fetches (seconds) and must not
328                // head-of-line-block other event consumption. It self-captures a guard + re-checks.
329                crate::db::spawn_bound(refresh_control(community_id, handler.clone()));
330            }
331            return;
332        }
333
334        let Some(channel) = COMMUNITY_ROUTES.lock().await.get(&pseudonym).cloned() else {
335            return;
336        };
337
338        let outcome = {
339            let mut state = crate::state::STATE.lock().await;
340            // Waiting on the lock is an await: a swap landing here would judge this
341            // event's authority against the NEW account's synced grant heads (an
342            // uncited hide is honored or dropped on the wrong account's state) and
343            // write the result into the wrong STATE.
344            inbound::process_incoming(&mut state, &event, &channel, &my_pk)
345        };
346        let chat_id = channel.id.to_hex();
347        match outcome {
348            Some(inbound::IncomingEvent::NewMessage(mut msg)) => {
349                // Resolve the reply preview (content/npub) from the DB before emitting,
350                // mirroring the DM realtime path. The replied-to message is often an
351                // older one that's persisted but outside the in-memory window; without
352                // this the recipient's live render finds no in-memory target and the
353                // reply shows as a plain message with no context.
354                if !msg.replied_to.is_empty() {
355                    let _ = crate::db::events::populate_reply_context(&mut msg).await;
356                }
357                // Bytes we already hold verify at arrival, mirroring the DM ingest —
358                // the box paints downloaded, nothing re-fetches.
359                crate::db::attachments::verify_message_attachments(&mut msg, Some(&chat_id)).await;
360                let _ = crate::db::events::save_message(&chat_id, &msg).await;
361                // This is the LIVE stream (limit-0 subscription) — back-paged history arrives via a
362                // separate one-shot batch, never here. But a relay backfilling from a peer pushes
363                // its newly-ACCEPTED old events to live subs too, so "arrived live" is not "fresh":
364                // is_new comes from the inner send time. Stale replays still persist + surface.
365                handler.on_community_message(&chat_id, &msg, super::is_realtime_fresh(msg.at));
366            }
367            Some(inbound::IncomingEvent::Updated { target_id, mut message, edit_event }) => {
368                // Edits are event-sourced (folded on reload); reactions re-save the message row.
369                if let Some(ev) = edit_event {
370                    let mut ev = (*ev).clone();
371                    if let Ok(cid) = crate::db::id_cache::get_chat_id_by_identifier(&chat_id) {
372                        ev.chat_id = cid;
373                    }
374                    let _ = crate::db::events::save_event(&ev).await;
375                } else {
376                    let _ = crate::db::events::save_message(&chat_id, &message).await;
377                }
378                // Re-resolve before emitting: this message came back out of STATE, whose
379                // compact form drops the quoted attachment's extension.
380                let _ = crate::db::events::populate_reply_context(&mut message).await;
381                handler.on_community_update(&chat_id, &target_id, &message);
382            }
383            Some(inbound::IncomingEvent::Removed { target_id }) => {
384                let _ = crate::db::events::delete_event(&target_id).await;
385                handler.on_community_removed(&chat_id, &target_id);
386            }
387            Some(inbound::IncomingEvent::ReactionRemoved { message_id, reaction_id, mut message }) => {
388                // Drop the reaction's kind-7 row (save is additive) and refresh the parent's chips.
389                let _ = crate::db::events::delete_event(&reaction_id).await;
390                let _ = crate::db::events::populate_reply_context(&mut message).await;
391                handler.on_community_update(&chat_id, &message_id, &message);
392            }
393            Some(inbound::IncomingEvent::Presence { npub, joined, event_id, created_at, invited_by, invited_label }) => {
394                handler.on_community_presence(
395                    &chat_id, &npub, joined, &event_id, created_at,
396                    invited_by.as_deref(), invited_label.as_deref(),
397                );
398            }
399            Some(inbound::IncomingEvent::WebxdcPeer { npub, topic_id, node_addr, event_id, created_at }) => {
400                handler.on_community_webxdc(
401                    &chat_id, &npub, &topic_id, node_addr.as_deref(), &event_id, created_at,
402                );
403            }
404            Some(inbound::IncomingEvent::Typing { npub, until }) => {
405                handler.on_community_typing(&chat_id, &npub, until);
406            }
407            Some(inbound::IncomingEvent::Kicked { community_id })
408            | Some(inbound::IncomingEvent::SelfLeft { community_id }) => {
409                crate::log_warn!("[v1:teardown {}] INBOUND Kicked/SelfLeft on the v1 channel plane", &community_id[..8.min(community_id.len())]);
410                // The handler owns teardown (the GUI prunes chats/relays + republishes the list; a
411                // headless consumer can call `teardown_local`). Core only routes + notifies here.
412                handler.on_community_self_removed(&community_id);
413            }
414            None => {}
415        }
416    })
417    .await
418}
419
420/// Realtime control-plane follow: walk a re-founding, fold the control plane (banlist/roles/
421/// metadata/invites), follow channel rekeys, then resubscribe at the new pseudonyms if any epoch
422/// advanced (else just refresh the route maps so the new banlist takes live effect). Mirrors the
423/// Tauri `refresh_community_control` orchestration over the already-core `catch_up_*` primitives.
424pub async fn refresh_control(community_id: String, handler: Arc<dyn InboundEventHandler>) {
425    crate::db::scoped(async move {
426        // Claim the in-flight slot or bail (a concurrent refresh is already folding this community).
427        {
428            let mut inflight = REFRESH_CONTROL_INFLIGHT.lock().unwrap_or_else(|e| e.into_inner());
429            if !inflight.insert(community_id.clone()) {
430                return;
431            }
432        }
433        struct RefreshClaim(String);
434        impl Drop for RefreshClaim {
435            fn drop(&mut self) {
436                REFRESH_CONTROL_INFLIGHT.lock().unwrap_or_else(|e| e.into_inner()).remove(&self.0);
437            }
438        }
439        let _claim = RefreshClaim(community_id.clone());
440
441        let Some(id_bytes) = hex_to_id32(&community_id) else { return; };
442        let Some(community) = crate::db::community::load_community(&CommunityId(id_bytes)).ok().flatten() else { return; };
443        let bt = LiveTransport::with_timeout(Duration::from_secs(20));
444        let pre_server_epoch = community.server_root_epoch.0;
445        let pre_channel_epochs: Vec<(String, u64)> =
446            community.channels.iter().map(|c| (c.id.to_hex(), c.epoch.0)).collect();
447
448        // FOLLOW FIRST: a privatize / private-ban re-founds the base under a NEW epoch + re-anchors the
449        // control plane there, so walk the rotation BEFORE folding control. An AUTHORIZED rotation that
450        // excluded us is a removal → tear down locally.
451        if let Ok(c) = service::catch_up_server_root(&bt, &community).await {
452            if c.removed {
453                crate::log_warn!("[v1:teardown {}] catch_up_server_root says REMOVED (v1 rekey walk)", &community_id[..8.min(community_id.len())]);
454                handler.on_community_self_removed(&community_id); return;
455            }
456        }
457        let community = crate::db::community::load_community(&CommunityId(id_bytes)).ok().flatten().unwrap_or(community);
458        let _ = service::fetch_and_apply_control(&bt, &community).await;
459        // Banned by the just-folded banlist → torn down, nothing more to do.
460        if let Some(c) = crate::db::community::load_community(&CommunityId(id_bytes)).ok().flatten() {
461            if service::am_i_banned(&c) {
462                crate::log_warn!("[v1:teardown {}] am_i_banned on the v1 refresh path", &community_id[..8.min(community_id.len())]);
463                handler.on_community_self_removed(&community_id);
464                return;
465            }
466        }
467
468        // Walk each channel's rekey chain. A re-founding rotates base AND every channel once; the channel
469        // rekey publishes right after the base rekey, so a single fetch can race propagation — retry with a
470        // short backoff until every channel reaches the expected epoch (next sync is the backstop).
471        let base_delta = crate::db::community::load_community(&CommunityId(id_bytes)).ok().flatten()
472            .map(|c| c.server_root_epoch.0).unwrap_or(pre_server_epoch).saturating_sub(pre_server_epoch);
473        for attempt in 0..CHANNEL_FOLLOW_MAX_ATTEMPTS {
474            let Some(cur) = crate::db::community::load_community(&CommunityId(id_bytes)).ok().flatten() else { break; };
475            for ch in &cur.channels {
476                let _ = service::catch_up_channel_rekeys(&bt, &cur, &ch.id).await;
477            }
478            let caught = base_delta == 0 || crate::db::community::load_community(&CommunityId(id_bytes)).ok().flatten()
479                .map(|c| c.channels.iter().all(|ch| {
480                    let pre = pre_channel_epochs.iter().find(|(id, _)| id == &ch.id.to_hex()).map(|(_, e)| *e).unwrap_or(ch.epoch.0);
481                    ch.epoch.0 >= pre.saturating_add(base_delta)
482                }))
483                .unwrap_or(true);
484            if caught { break; }
485            if attempt + 1 < CHANNEL_FOLLOW_MAX_ATTEMPTS {
486                crate::rt::time::sleep(Duration::from_millis(CHANNEL_FOLLOW_BACKOFF_MS)).await;
487            }
488        }
489        let community = crate::db::community::load_community(&CommunityId(id_bytes)).ok().flatten().unwrap_or(community);
490        let _ = service::retry_pending_read_cut(&bt, &community).await;
491        let community = crate::db::community::load_community(&CommunityId(id_bytes)).ok().flatten().unwrap_or(community);
492
493        // If an epoch advanced, rebuild the FULL subscription so realtime delivery resumes at the new
494        // pseudonyms; else just refresh the route maps so the inbound drop-filter sees the new banlist.
495        let advanced = community.server_root_epoch.0 != pre_server_epoch
496            || community.channels.iter().any(|c| {
497                pre_channel_epochs.iter().find(|(id, _)| id == &c.id.to_hex()).map(|(_, e)| *e != c.epoch.0).unwrap_or(true)
498            });
499        if advanced {
500            if let Some(client) = crate::state::nostr_client() {
501                refresh_subscription(&client).await;
502            }
503        } else {
504            let _ = rebuild_routes().await;
505        }
506        crate::community::list::refresh_membership_current(&community);
507        handler.on_community_refreshed(&community_id);
508    })
509    .await
510}
511
512/// Tear down a community locally on a received removal (kick/leave/ban), RETAINING the held epoch
513/// keys (so a self-scrub republish/erase still works), then refresh the subscription so we stop
514/// listening on its pseudonyms. Headless consumers can call this from `on_community_self_removed`;
515/// the GUI does a richer teardown (prune chats/relays + republish the list) in its handler instead.
516pub async fn teardown_local(community_id: &str) {
517    let _ = crate::db::community::delete_community_retain_keys(community_id);
518    if let Some(client) = crate::state::nostr_client() {
519        refresh_subscription(&client).await;
520    }
521}
522
523/// hex (64 chars) → 32-byte id.
524fn hex_to_id32(hex: &str) -> Option<[u8; 32]> {
525    (hex.len() == 64).then(|| crate::simd::hex::hex_to_bytes_32(hex))
526}