From 28c987cfe73d814afa128d42baf46936ff97e732 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 17 Sep 2026 11:28:28 +0700 Subject: [PATCH] update doc --- docs/concord-usage.md | 118 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 108 insertions(+), 10 deletions(-) diff --git a/docs/concord-usage.md b/docs/concord-usage.md index 03599d02..eb68470e 100644 --- a/docs/concord-usage.md +++ b/docs/concord-usage.md @@ -408,23 +408,121 @@ let event = list::build_list_event(&my_keys, &mine)?; // kind 13302, NIP-44 strictly newer join outruns it. `fits()` is the write gate — 50 memberships and the NIP-44 size cap, both protocol constants. -## GPUI conventions +## GPUI integration -- Wrap, decrypt, verify, fold and every database or relay call go in - `cx.background_spawn`. A secp256k1 verification per edition is far too - expensive for the foreground thread. -- Hold entities foreground: `cx.spawn` with `this.update(cx, |this, cx| …)` and - the inner `cx`, keeping the returned `Task` in a field so it is cancelled with - the view. -- In tests, use `cx.background_executor().timer(..)` for delays, never - `smol::Timer`, or `run_until_parked()` will find nothing left to run. +`crates/concord` stays GPUI-free. The UI layer adds a registry global and one +entity per community, and moves every decrypt, verification, fold and I/O off +the foreground thread. + +### Entities + +Same shape as `ChatRegistry`: + +```rust +pub fn init(window: &mut Window, cx: &mut App) { + ConcordRegistry::set_global(cx.new(|cx| ConcordRegistry::new(window, cx)), cx); +} + +impl ConcordRegistry { + pub fn global(cx: &App) -> Entity { + cx.global::().0.clone() + } +} +``` + +Call it after `chat::init` in `desktop/src/main.rs` and `web/src/lib.rs`, and +subscribe to `NostrRegistry` for `SignerChanged` so the communities reset with +the account. + +- `ConcordRegistry` holds `communities: Vec>`, an index by + `CommunityId`, and `tasks: SmallVec<[Task>; 2]>`. +- `Community` owns one `CommunityState`, the last `ControlFold`, the member list + and the channel list. Views render `Entity`; no protocol state + lives in a view. +- `CommunityState::apply_fold` is one assignment: run it in the task that + produced the fold and send only the result to the foreground. +- Emit an event on every fold so dependents re-read. + +### Foreground and background + +A background task never touches an entity. It sends results through a bounded +`flume` channel that a foreground `cx.spawn` drains with `this.update(...)`. + +```rust +let (signal_tx, signal_rx) = flume::bounded::(256); +let database = client.database().clone(); + +// Background: open, verify, fold — no entities. +self.ingress = Some(cx.background_spawn(async move { + for wrap in &wraps { + let Some(plane) = planes.iter().find(|plane| plane.group.pk() == wrap.pubkey) else { + continue; + }; + let (opened, rumor) = chat::open(wrap, &plane.group, &plane.channel, plane.epoch)?; + store::cache_rumor(database.as_ref(), &plane.channel, &opened).await?; + signal_tx.send_async(Signal::Chat { channel: plane.channel, rumor }).await?; + } + Ok(()) +})); + +// Foreground: the only place entities change. +self.consumer = Some(cx.spawn(async move |this, cx| { + while let Ok(signal) = signal_rx.recv_async().await { + this.update(cx, |this, cx| this.apply(signal, cx))?; + } + Ok(()) +})); +``` + +- `client.database()` is a `&Arc` and `store::save_state` + wants `&dyn NostrDatabase`, so clone the `Arc` and pass `database.as_ref()`. +- Keep long-lived tasks in fields — dropping a `Task` cancels it. Assign `None` + to an `Option>` before respawning it; a signer change replaces both + the listener and the consumer. +- `cx.spawn` when the work updates an entity after awaiting, and + `cx.background_spawn` when it only produces a value. A query the foreground awaits can be returned straight out: `fn messages(&self, cx: &App) -> Task, Error>>`. +- Do the first load in `cx.defer_in(window, ...)` so `init` returns before the + first relay request. +- NIP-46 signing is async: call `signer.get_public_key_async()` / + `sign_event_async` inside the background task. The builders still take + `&Keys`, so run them where device keys are available. + +### Subscriptions + +A wrap is addressed to a plane, so the plane's public key is the routing key and +one `Filter` per held plane is enough: + +```rust +let filter = Filter::new() + .kinds([Kind::from(KIND_WRAP), Kind::from(KIND_WRAP_EPHEMERAL)]) + .pubkey(plane.group.pk()) + .since(joined_at); +client.subscribe(filter).with_id(sub_id).await?; +``` + +- `pubkeys([...])` carries every plane of a community on one subscription. Call + `subscribe` again with the new address whenever a join, a channel add or a + rekey fold changes it. +- Route inbound events by `subscription_id` from `RelayMessage::Event`, never by + kind. +- Watch one epoch ahead: while holding `root_N`, subscribe to + `base_rekey_group_key(&root_N, &community_id, Epoch(N + 1))` and to + `channel_rekey_group_key(&root_N, &channel, Epoch(N + 1))` for each private + channel. A second epoch ahead is not derivable until the new root arrives. + +### Tests + +`cx.background_executor().timer(..)` for delays, never `smol::Timer`, or +`run_until_parked()` finds nothing left to run. Push a wrap into the channel and +`run_until_parked()` to drive the foreground consumer. ## Not wired up yet - **No registry and no sync engine.** `crates/concord` has no subscriptions, no `init`, and no `Entity`; the UI owns subscribing, routing a wrap to the plane whose address it carries, and rebuilding a subscription when a plane's - address changes (join, channel added, rekey folded). + address changes (join, channel added, rekey folded). GPUI integration above is + the shape to build, not code that exists. - **Every writer takes `&Keys`, not a `NostrSigner`.** NIP-46 is one deliberate pass over the builders, not a per-call patch. - **`crates/chat/src/lib.rs::handle_notifications` treats every kind 1059 event as