update doc
This commit is contained in:
+108
-10
@@ -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
|
strictly newer join outruns it. `fits()` is the write gate — 50 memberships and
|
||||||
the NIP-44 size cap, both protocol constants.
|
the NIP-44 size cap, both protocol constants.
|
||||||
|
|
||||||
## GPUI conventions
|
## GPUI integration
|
||||||
|
|
||||||
- Wrap, decrypt, verify, fold and every database or relay call go in
|
`crates/concord` stays GPUI-free. The UI layer adds a registry global and one
|
||||||
`cx.background_spawn`. A secp256k1 verification per edition is far too
|
entity per community, and moves every decrypt, verification, fold and I/O off
|
||||||
expensive for the foreground thread.
|
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
|
### Entities
|
||||||
the view.
|
|
||||||
- In tests, use `cx.background_executor().timer(..)` for delays, never
|
Same shape as `ChatRegistry`:
|
||||||
`smol::Timer`, or `run_until_parked()` will find nothing left to run.
|
|
||||||
|
```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<Self> {
|
||||||
|
cx.global::<GlobalConcordRegistry>().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<Entity<Community>>`, an index by
|
||||||
|
`CommunityId`, and `tasks: SmallVec<[Task<Result<(), Error>>; 2]>`.
|
||||||
|
- `Community` owns one `CommunityState`, the last `ControlFold`, the member list
|
||||||
|
and the channel list. Views render `Entity<Community>`; 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::<Signal>(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<dyn NostrDatabase>` 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<Task<_>>` 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<Result<Vec<ChatMessage>, 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
|
## Not wired up yet
|
||||||
|
|
||||||
- **No registry and no sync engine.** `crates/concord` has no subscriptions, no
|
- **No registry and no sync engine.** `crates/concord` has no subscriptions, no
|
||||||
`init`, and no `Entity<Community>`; the UI owns subscribing, routing a wrap to
|
`init`, and no `Entity<Community>`; the UI owns subscribing, routing a wrap to
|
||||||
the plane whose address it carries, and rebuilding a subscription when a plane's
|
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
|
- **Every writer takes `&Keys`, not a `NostrSigner`.** NIP-46 is one deliberate
|
||||||
pass over the builders, not a per-call patch.
|
pass over the builders, not a per-call patch.
|
||||||
- **`crates/chat/src/lib.rs::handle_notifications` treats every kind 1059 event as
|
- **`crates/chat/src/lib.rs::handle_notifications` treats every kind 1059 event as
|
||||||
|
|||||||
Reference in New Issue
Block a user