From 1b6ef6f57451f3843c895a27cacbf4a2790a5047 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 18 Sep 2026 20:06:16 +0700 Subject: [PATCH] clean up --- Cargo.lock | 2 + crates/chat/src/lib.rs | 16 +- desktop/Cargo.toml | 1 + desktop/src/main.rs | 3 + docs/community-plan.md | 328 ----------------------------------------- web/Cargo.toml | 1 + web/src/lib.rs | 3 + 7 files changed, 25 insertions(+), 329 deletions(-) delete mode 100644 docs/community-plan.md diff --git a/Cargo.lock b/Cargo.lock index 93148ce2..0e822f8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1429,6 +1429,7 @@ dependencies = [ "auto_update", "chat", "common", + "community", "device", "gpui-pre", "gpui-pre-linux", @@ -1454,6 +1455,7 @@ dependencies = [ "assets", "chat", "common", + "community", "console_error_panic_hook", "console_log", "device", diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index 0563c87c..e1dd9dca 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -221,7 +221,21 @@ impl ChatRegistry { }; match *message { - RelayMessage::Event { event, .. } => { + RelayMessage::Event { + subscription_id, + event, + .. + } => { + let chat_sub = subscription_id.as_str() != sub_id1.as_str(); + let device_sub = subscription_id.as_str() != sub_id2.as_str(); + + // Concord wraps are also kind 1059. + // + // Only the two gift wrap subscriptions carry NIP-59 wraps for this account. + if event.kind == Kind::GiftWrap && chat_sub && device_sub { + continue; + } + // Prune the dedup set before it grows unbounded if processed_events.len() >= MAX_PROCESSED { processed_events.clear(); diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index a18c4203..82568676 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -35,6 +35,7 @@ common = { path = "../crates/common" } state = { path = "../crates/state" } device = { path = "../crates/device" } chat = { path = "../crates/chat" } +community = { path = "../crates/community" } settings = { path = "../crates/settings" } auto_update = { path = "../crates/auto_update" } person = { path = "../crates/person" } diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 86985ace..57779b65 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -102,6 +102,9 @@ fn main() { // Initialize app registry chat::init(window, cx); + // Initialize community registry + community::init(window, cx); + // Initialize auto update auto_update::init(window, cx); diff --git a/docs/community-plan.md b/docs/community-plan.md deleted file mode 100644 index 6ce8c3c7..00000000 --- a/docs/community-plan.md +++ /dev/null @@ -1,328 +0,0 @@ -# The community registry — implementation plan - -`crates/community` is the GPUI layer over `crates/concord`: a global registry, one -entity per community, and one notification stream that keeps both fed from the -local database. `crates/concord` stays GPUI-free; this crate is the only place -where entities, tasks and subscriptions meet. - -The shape follows `crates/chat`: a registry global created in `init`, entities -that own protocol state, a background notification listener, and a foreground -consumer that is the only writer of entity state. - -## Scope - -In: - -- discovery of the current account's communities from the local database; -- one `Entity` owning `CommunityState`, the last `ControlFold`, the - folded member list and the channel list; -- subscriptions to every held plane (Control, Guestbook, public Channels); -- notification-driven refresh: open and cache wraps in the background, fold them - there too, and apply only the result on the foreground; -- re-subscription when a fold moves a plane address or a relay list changes. - -Out (see "Known gaps"): rekeys, private-channel keys, sends, moderation, -invites, chat timelines, and NIP-46 writers. - -## Crate layout - -| File | Owns | -| --- | --- | -| `community.rs` | The `Community` entity, `CommunityEvent`, refresh coalescing | -| `lib.rs` | `init`, `CommunityRegistry`, the signal channel, subscription sync | -| `sync.rs` | Planes, the REQ filter, loading from the database, the fold | - -## Entities - -`CommunityRegistry` mirrors `ChatRegistry`: - -```rust -pub struct CommunityRegistry { - communities: Vec>, - index: HashMap>, - synced: HashMap, - signal_tx: flume::Sender, - signal_rx: flume::Receiver, - tasks: SmallVec<[Task>; 2]>, - notification_listener: Option>>, - signal_consumer: Option>>, - _subscriptions: SmallVec<[Subscription; 2]>, -} -``` - -```rust -pub enum CommunityEvent { - Updated(CommunityId), - Error(String), -} -``` - -- `Updated` is emitted by a `Community` after a fold is applied; the registry - emits only `Error`. Views subscribe where they render. -- `Community` holds `state: CommunityState`, `control: ControlFold`, - `members: BTreeSet`, a `dirty` flag and one in-flight - `refresh_task`. Public reads: `id()`, `state()`, `control()`, `members()`, - `channels()`. -- The registry is not an emitter of `Updated`: a view holds - `Entity` and observes that. - -## Loading - -The registry loads when the signer changes and once at startup through -`cx.defer_in`. - -- State documents are discovered by scanning the local database for - `Kind::ApplicationSpecificData` events whose `d` tag is `concord/`, - newest per community. This is the bootstrap path: `cord02::genesis` + - `store::save_state` (the creation flow in `concord-usage.md`) writes no list - entry, so a list-only load would show nothing until joining exists. -- The Community List (`kind 13302`, NIP-44 to self) is read from the database - without `fetch_events`; decryption goes through `UniversalSigner::nip44_decrypt_async`, - so NIP-46 signers work. When a list exists it is authoritative for liveness: - a state document whose id is not live (no entry, or a newer tombstone) is - dropped. With no list event, every state document loads. -- `cord02::list::parse_list_event` takes `&Keys`, which the UI layer does not - hold, so the list is decrypted with the signer and parsed as - `CommunityList` directly. Nothing in the list is rewritten here. - -Creation and join flows persist a `CommunityState` themselves and call -`CommunityRegistry::reload`; the registry grows no writer APIs it cannot -correctly support. - -## Subscriptions - -A community's planes are derived from its state; the wrap's *author* is the -routing key. - -| Plane | Read key | Wrap author (`Filter::authors`) | -| --- | --- | --- | -| Control, each held epoch | `control_group_key(root, id, epoch)` | `state.control_pks[epoch]` (the signer pk) | -| Guestbook | `guestbook_group_key(root, id, root_epoch)` | the group's own pk | -| Channel (public only) | `channel_group_key(root, channel, channel.epoch)` | the group's own pk | - -One caveat on the snippet in `concord-usage.md`: it uses -`Filter::new().pubkey(plane.pk())`, but in this nostr-sdk `Filter::pubkey` adds a -`#p` tag constraint, and a Concord wrap's `p` tag is a random ephemeral key -(`cord01::wrap_seal_with`). The filter must be `.authors(...)`, matching the -event author that `open_wrap_at` already checks. - -- One subscription id per community: `SubscriptionId::new("concord/")`. - Routing back from a notification is a prefix strip and a hex parse. -- `Community` exposes a cheap `SubscriptionKey` (control pks, channels + epochs - + privacy, relays). When a fold changes it, the registry re-subscribes: - `unsubscribe` then `subscribe` with the same id. -- Community relays are added to the client explicitly - (`client.add_relay(..).and_connect()`), per `concord-usage.md`. - -## Notification stream - -`client.notifications()` is one stream for the whole app; the community listener -takes the first-seen variant and routes by subscription id, never by kind: - -```rust -while let Some(notification) = notifications.next().await { - let ClientNotification::Event { subscription_id, event, .. } = notification else { - continue; - }; - if event.kind != Kind::from(KIND_WRAP) { - continue; - } - let Some(id) = sync::community_of(&subscription_id) else { - continue; - }; - tx.send_async(Signal::Event(id)).await?; -} -``` - -- `ClientNotification::Event` fires only the first time an event is seen; the - relay has already saved it to the local database before notifying - (`nostr-sdk` relay inner), so a signal only needs the community id and the - fold reads the wrap back from the database. This is also what makes restart - work: a backlog already in the database produces no notification, so - `Community::refresh` runs once when the community is tracked. -- `KIND_WRAP_EPHEMERAL` (21059, typing) is not subscribed: ephemeral events are - never persisted, so the database-read path cannot see them. Nothing in the - registry consumes typing today. -- The channel is `flume::bounded(256)`; the consumer is a foreground `cx.spawn` - that updates entities, as in `concord-usage.md`. - -The chat registry must route gift wraps by subscription id before community -subscriptions go live, or every stream wrap lands in the DM trash: - -```rust -RelayMessage::Event { subscription_id, event } => { - if event.kind == Kind::GiftWrap - && subscription_id.as_ref() != sub_id1.as_str() - && subscription_id.as_ref() != sub_id2.as_str() - { - continue; - } - // .. -} -``` - -The `InboxRelays` handling in the same loop stays unscoped: it arrives on a -short-lived subscription with a generated id. - -## The fold - -One background function, `sync::fold(database, state) -> Snapshot`, does all -crypto, verification, I/O and folding. `Snapshot` carries the applied -`CommunityState`, the `ControlFold` and the member set; the foreground only -assigns. - -1. Derive the held planes. -2. For each plane, query the database for `KIND_WRAP` events authored by the - plane address and open them: - - Control: `cord02::open_edition(wrap, read, address, true)` → `ParsedEdition`; - - Guestbook: `cord02::guestbook::open(wrap, group)` → `GuestbookRumor`; - - Channel: `cord03::open(wrap, group, channel, epoch)` then - `store::cache_rumor` — the chat read path is already database-backed. - Collect `observed: PublicKey -> ms` from every author that opened. -3. `cord02::fold_control(owner, id, &editions, &state.floors(), &state.banned)`, - then `state.apply_fold` and `store::save_state`, all in this task. If no - edition opened at all, the fold is not applied: an empty fold would erase the - committed floors the next fold is judged against. -4. `cord02::guestbook::coalesce` with the roster-backed `can_kick` - (`citation_ok` + `can_act_on_member(.., Permissions::KICK)`), then - `complete_memberlist` with `observed`, the roster's grants, `control.banned` - and an empty `banned_at`. The owner is inserted explicitly — the roster does - not mint an implicit grant for them. - -## Foreground and background - -- Every entity touch and every fold application happens on the foreground. - Background tasks only read the database and return values. -- `Community::refresh` coalesces: if a fold is in flight it sets `dirty`, and - the completion applies the snapshot, clears the task, then runs one more fold - if dirtied. A burst of backlog events produces at most two folds. -- `refresh_task: Option>` is dropped on reset, which cancels it. -- The registry observes each community (`cx.observe`) and re-syncs - subscriptions when a fold changed a plane or relay set; sync is a key - comparison, so ordinary notifies are a no-op. -- Errors from load/subscribe/fold reach the UI as `CommunityEvent::Error`; a - task whose result is never read must not be the only error path. - -## Integration - -- `community::init(window, cx)` in `desktop/src/main.rs` and `web/src/lib.rs` - after `chat::init`. -- Chat's notification routing fix above. -- `concord::store::{save_state, load_state}` gain `+ ?Sized` on `D`: the - integration path passes `&dyn NostrDatabase` (the doc's advice cannot compile - against the current bound). `cache_rumor`, `query_rumors`, `purge_expired` - and `backfill` already take `&dyn`. - -## Tests - -Pure `#[test]` with `MemoryDatabase` and `smol::block_on`, like `concord`'s -store tests; no GPUI test context (no registry test exists in this repo, and -`state::init` owns the global client). - -1. genesis folds into metadata, the general channel, and an owner-only member - list, and the folded state is persisted. -2. a member's join becomes a member and a later leave removes them. -3. the subscription filter asks for every held plane by wrap author, and - `community_of(subscription_id(id)) == Some(id)`. -4. loading: state documents load without a list; a Community List entry keeps a - community and a newer tombstone hides it. - -## Known gaps - -- **Rekeys are not adopted.** `CommunityState` cannot hold a second root or a - channel key, so the rekey planes (one epoch ahead) are not subscribed. A - community stays on the plane set its state can derive. -- **Private channels are skipped**, not guessed: no key is held for them yet. -- The fold re-opens every wrap on every refresh. `ClientNotification::Event` - plus refresh coalescing keep it bounded, and the database read path stays - simple; incremental caches are a follow-up. -- `list.is_live` filtering is only as fresh as the last list event; the - registry never writes list or state documents for the user's account. - -## Phases - -Phases are sequential: each one lands compiling code and has an exit check. Nothing -in a later phase is started before the earlier one is green, so the crate is never -in a half-wired state. - -### Phase 0 — Decisions - -Five calls to confirm before writing code. Defaults in brackets. - -1. **v1 planes** [Control + Guestbook + public Channels]. Rekeys and private - channels are out of scope, not stubs. -2. **Discovery and liveness** [scan the local DB for `concord/` state - documents; read the Community List when present and use `is_live` to drop - tombstones; never republish the list]. -3. **Wiring** [`community::init` after `chat::init` in `desktop` and `web`]. -4. **Chat routing fix** [route kind 1059 by subscription id in - `chat::handle_notifications`; leave `InboxRelays` unscoped]. -5. **`concord::store` bound** [add `+ ?Sized` to `save_state`/`load_state` so - `&dyn NostrDatabase` compiles; the doc's snippet does not compile today]. -6. **Tests** [pure `#[test]` + `smol::block_on` + `MemoryDatabase`; no GPUI test - context]. - -Exit: all six confirmed. Any that change rewrite the affected phase below. - -### Phase 1 — Crate skeleton - -- Create `crates/community/Cargo.toml` and `src/{lib,community,sync}.rs` stubs. -- Deps: `concord`, `state`, `gpui`, `nostr-sdk`, `anyhow`, `flume`, `log`, - `serde_json`, `smallvec`. Dev: `nostr-memory`, `smol`. -- The workspace already globs `crates/*`, so no root manifest edit. - -Exit: `cargo check -p community` passes with the empty modules. - -### Phase 2 — `sync.rs` - -Pure, GPUI-free plumbing: `Plane`/`PlaneKind`, `planes(&CommunityState)`, -`subscription_filter` (using `.authors(...)`, not `.pubkey(...)`), the -`SubscriptionId`/`community_of` round-trip, `load`, and `fold -> Snapshot`. - -Exit: unit tests 3 and 4 pass; no GPUI types in the file. - -### Phase 3 — `community.rs` - -`Community` entity (`state`, `control`, `members`, `dirty`, in-flight -`refresh_task`), `CommunityEvent::{Updated, Error}`, and coalesced refresh that -spawns the fold on `background_spawn` and applies the snapshot on the foreground. - -Exit: tests 1 and 2 pass; entity compiles against a `TestAppContext`-free test. - -### Phase 4 — `lib.rs` - -`init` plus `CommunityRegistry`: bounded `flume(256)` signal channel, the -notification listener task, the foreground consumer, `SubscriptionKey` re-sync on -observe, and `reset`/`reload` on `StateEvent::SignerChanged`. - -Exit: registry starts and stops cleanly under `cargo check`; listener routes by -subscription id only. - -### Phase 5 — Cross-crate fixes - -- `concord::store::{save_state, load_state}` gain `+ ?Sized`. -- Chat gift-wrap routing fix. - -Exit: `cargo check -p concord -p chat` passes; no behaviour change for DM-only -clients. - -### Phase 6 — App wiring - -Call `community::init(window, cx)` after `chat::init` in `desktop/src/main.rs` -and `web/src/lib.rs`. - -Exit: `cargo check -p coop` (or the app targets) passes. - -### Phase 7 — Validation - -Run the four tests plus `cargo check` and `cargo test` for `community`, then the -workspace. - -Exit: all green, or failing lines reported with root cause. - -### Phase 8 — Doc finalization - -Reconcile this document with what actually landed (scope, gaps, test names) and -remove the draft's speculative sections that were cut. - -Exit: the doc matches the code. diff --git a/web/Cargo.toml b/web/Cargo.toml index 724ec6ae..0f172056 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -16,6 +16,7 @@ common = { path = "../crates/common" } state = { path = "../crates/state" } device = { path = "../crates/device" } chat = { path = "../crates/chat" } +community = { path = "../crates/community" } settings = { path = "../crates/settings" } person = { path = "../crates/person" } diff --git a/web/src/lib.rs b/web/src/lib.rs index df97a5d4..9bb87f6d 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -72,6 +72,9 @@ pub fn run() -> Result<(), JsValue> { // Initialize app registry chat::init(window, cx); + // Initialize community registry + community::init(window, cx); + // Root view cx.new(|cx| Root::new(workspace::init(window, cx).into(), window, cx)) })