From 338783f394e592f122c9b1bfe01167df01a66d2a Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 30 Sep 2026 06:26:10 +0700 Subject: [PATCH 01/27] add plan --- docs/nip29-backend-plan.md | 476 +++++++++++++++++++++++++++++++++++++ 1 file changed, 476 insertions(+) create mode 100644 docs/nip29-backend-plan.md diff --git a/docs/nip29-backend-plan.md b/docs/nip29-backend-plan.md new file mode 100644 index 00000000..c967da00 --- /dev/null +++ b/docs/nip29-backend-plan.md @@ -0,0 +1,476 @@ +# NIP-29 groups backend + +Coop has two relay-backed backends today: `crates/chat` (NIP-17 gift-wrap DMs, +rendered by `crates/chat_ui`) and `crates/community` (Concord). This document is a +plan, not a description of shipped code; nothing below is wired up yet. It covers +the backend only — reading, writing, joining and leaving relay-based groups as +defined by [NIP-29](https://github.com/nostr-protocol/nips/blob/master/29.md) — +and ends with the hooks a UI will use later. + +## The spec in backend terms + +A group is a `(relay, id)` pair. The relay enforces membership and signs the +group's state; there is no cross-relay identity, no encryption, and no portable +"create". The same `id` on two relays is two groups (a fork), which the plan +models as two entities. + +| Concern | Wire | Backend rule | +| --- | --- | --- | +| Group id | random string, relay-chosen | `h` tag on everything a user sends; `d` tag on relay state events | +| Timeline | any event kind + `h` | v1 renders kinds `1`, `9`, `11`, `1111` and `7`; every other kind is cached for the `previous` window but not rendered, except the membership kinds below | +| Timeline refs | `previous` tag, first 8 hex chars of event ids | built from the last 50 events seen on that relay (any kind — NIP-29 timeline references), excluding our own; send up to 3 | +| Relay state | `39000` metadata, `39001` admins, `39002` members, `39003` roles | relay-signed, addressable by `d`; read, never written by the client | +| Membership | `9021` join, `9022` leave (user-signed); relay answers with `9000` put-user / `9001` remove-user | membership = latest `9000`/`9001` naming our pubkey; no event means not a member, and `Pending`/`Refused` are tracked locally for private groups | +| Own group list | `10009` (NIP-51 simple groups) | `["group", id, relay, name?]` plus `["r", relay]`; replaceable, published to NIP-65 relays | +| Reference | `naddr1…` of kind `39000` with the relay self pubkey and a relay hint, optionally `?invite=` | the self pubkey is the author of the fetched `39000`; no `39000` means no canonical naddr | + +Two behaviours that shape the code: + +- **Late publication is rejected.** Relays drop messages with old timestamps, so + there is no offline send queue in v1: a send either goes now or fails visibly. +- **`previous` must reference events the relay already has.** Relays may enforce + at least 3 references; a group we have little history for may have its sends + rejected until the backlog loads. The relay's `OK false` message is surfaced + as-is. + +### Not in v1 + +Moderation writes (`9000`–`9010`), group creation (`9007`, relay-dependent), +pinned events (`39005`/`9010`), subgroups (`parent`/`child` — parsed but inert), +LiveKit audio/video (`livekit` tag, `39004`, the token endpoint), fork/migration +detection through admins' `10009`, and relay browsing (`{"kinds":[39000]}` to +list a relay's public groups). All are additive; none change the model above. + +## What the local rust-nostr checkout gives us + +Cross-checked against the pinned checkout +(`~/.cargo/git/checkouts/nostr-619b808bb247a9ed/01f1950`): + +- **Kinds exist** for every NIP-29 event in v1: `Kind::GroupPutUser` (9000), + `GroupRemoveUser` (9001), `GroupEditMetadata` (9002), `GroupDeleteEvent` + (9005), `GroupCreateGroup` (9007), `GroupDeleteGroup` (9008), + `GroupCreateInvite` (9009), `GroupJoinRequest` (9021), `GroupLeaveRequest` + (9022), `GroupMetadata` (39000), `GroupAdmins` (39001), `GroupMembers` + (39002), `GroupRoles` (39003), `GroupLivekitParticipants` (39004), and + `Kind::SimpleGroups` (10009). `Kind::ChatMessage` is kind 9. +- **`9010` and `39005` are missing** from this revision (`Kind::Custom(9010)`, + `Kind::Custom(39005)` if they are ever needed — they are not, in v1). +- **There is no NIP-29 module.** `nostr/src/nips/` has no `nip29.rs`, and no + `GroupId`, metadata type, parser or builder exists. Everything in `protocol.rs` + below is ours; it is thin — tags and filters, no crypto. +- **The APIs to lean on** are all present and already used in the app: + - `Tag::custom`, `Tag::event`, `Tag::identifier`, `Tag::public_key`, + `Tag::content`, `Tag::as_slice`. + - `Filter::custom_tag(SingleLetterTag::LOWERCASE_H, id)`, + `.identifier(id)`, `.author`, `.kinds`, `.limit`, `.since`. + - `ReqTarget::single(url, filters)` for a group-scoped REQ, + `client.subscribe(..).with_id(..)`, `client.fetch_events(..)`, + `client.send_event(&event).to([url])`. + - `EventBuilder::new(kind, content).tags(tags).finalize_async(&signer)` — the + app's `UniversalSigner` works here (NIP-46 included), as it does in + `crates/chat/src/room.rs`. + - `client.database().query/save_event/delete`. + - `Nip19Coordinate { coordinate, relays }` with `ToBech32`/`FromBech32` for + `naddr`. +- **NIP-11 is not exposed.** `Relay` in this SDK has no document accessor, so the + relay self pubkey is taken from the author of the group's `39000` event; a + group whose relay publishes no `39000` therefore has no canonical naddr and + cannot be shared or joined in v1. + +## What the app already does (and we copy) + +The NIP-17 backend is the template. `crates/chat` has a global `ChatRegistry` +entity, one `Room` entity per conversation, a background task over +`client.notifications()` that sends signals through a `flume` channel, and a +foreground `cx.spawn` consumer that is the only place entities change. Local +messages are cached as `ApplicationSpecificData` (30078) events with a `d` tag +for the target id and an `r` tag for the room key (`set_rumor`/`get_rumor`, +`crates/chat/src/lib.rs`). `crates/chat_ui` renders `chat::Message` into a +`ListState`, resolving names through `PersonRegistry`. + +`crates/community` shows the same pattern for relay-scoped state: routing +notifications by subscription id (`route_of`), bringing relays into the pool +before use (`connect_relays`), and treating a `CLOSED` message as a refusal. +Replacing a list is done in `crates/workspace/src/panels/contact_list.rs`: +sign the builder, `client.send_event(&event).to_nip65().await` — the client saves +the event locally even when the publish fails. + +NIP-29 reuses all of this and drops what only NIP-17 needs: no encryption, no +per-member fan-out, no gift wraps, no `SendReport` per receiver — one relay, one +event, one `OK`. + +## Where this lands + +A new crate `crates/nip29`, because NIP-29 is a different transport (plain relay +events), a different state authority (relay-signed `39000`s) and a different UI +later. Making `chat::Room` polymorphic over gift-wrapped DMs and relay groups +would put two protocols in one entity. The alternative — modules inside +`crates/chat` — saves a `Cargo.toml` but was set aside (see Decisions). + +| File | Owns | +| --- | --- | +| `crates/nip29/src/protocol.rs` | Pure NIP-29 types and their `impl` blocks: `GroupId`, `GroupKey`, `GroupReference`, `GroupMetadata`, admins/members/roles, `GroupList`, `Membership`, `TimelineWindow`. No I/O. | +| `crates/nip29/src/group.rs` | `Group` entity: state, timeline, membership, `connect`, `ingest`, `send_message`, `send_reaction`, `join`, `leave`, `GroupEvent`. | +| `crates/nip29/src/lib.rs` | `GroupsRegistry` global, `init`, standing subscriptions and the signal pump, the envelope cache, `load`/`publish` of the account's `10009`. | +| `desktop/src/main.rs`, `web/src/lib.rs` | `nip29::init(cx)` after `chat::init(cx)`. | +| UI later | `crates/chat_ui` (reuse `chat::Message` rendering) or a new `crates/nip29_ui`; sidebar section; a join dialog. | + +`protocol` is re-exported at the crate root (`pub use protocol::*`) so callers +write `nip29::GroupId`, `nip29::GroupMetadata`. The crate's dependencies are the +ones `chat` already carries: `state` (client and signer), `chat` (`Message`), +`gpui`, `nostr-sdk`, `anyhow`, `flume`, `futures`, `smallvec`, `log`; +dev-dependencies `nostr-memory` and `nostr-sdk` with the `local-relay` feature +for the tests below. + +## Data flow + +``` +own 10009 (NIP-65 relays) group relay (one per group) + │ load / subscribe │ REQ #h= (timeline + 9000/9001) + ▼ │ REQ #d= (39000..39003) +GroupsRegistry ──ensure relay──► Group entity ◄──ingest── background listener + │ index: GroupKey │ (saves envelope first) + │ │ + └── flume Signal ◄──────────────────────┘ + ▲ + UI later: GroupPanel (Vec), JoinDialog (naddr) +``` + +## Phase 0 — `crates/nip29/src/protocol.rs` (pure, no I/O) + +The protocol is expressed as types with `impl` blocks: a parsed thing is a struct +and every operation on it is a method, so call sites read `id.message(...)`, +`metadata.naddr(...)`, `admins.contains(&pk)` instead of handing the pieces to +free builder functions. + +```rust +/// The relay-chosen group id. +pub struct GroupId(String); + +impl GroupId { + pub fn new(id: impl Into) -> Result; // trimmed, non-empty + pub fn as_str(&self) -> &str; + pub fn h_tag(&self) -> Tag; // ["h", id] + + pub fn timeline_filter(&self, limit: usize) -> Filter; // #h = id + pub fn state_filter(&self) -> Filter; // #d = id, kinds 39000..39003 + pub fn membership_filter(&self, me: PublicKey) -> Filter; // #h = id, #p = me, kinds 9000/9001 + + pub fn message(&self, content: String, replies: &[EventId], previous: Option) -> EventBuilder; // kind 9 + pub fn reaction(&self, target: EventId, emoji: String, previous: Option) -> EventBuilder; // kind 7 + e + pub fn join_request(&self, code: Option<&str>, reason: &str) -> EventBuilder; // 9021 + pub fn leave_request(&self, reason: &str) -> EventBuilder; // 9022 +} + +/// Entity identity: the same id on another relay is another group (a fork). +pub struct GroupKey { relay: RelayUrl, id: GroupId } + +impl GroupKey { + pub fn new(relay: RelayUrl, id: GroupId) -> Self; + pub fn relay(&self) -> &RelayUrl; + pub fn id(&self) -> &GroupId; + pub fn cache_tag(&self) -> String; // "|" + pub fn uniq_id(&self) -> u64; // hashed, for panel and element ids +} + +/// What a user pastes to join: naddr1…(+"?invite="). +pub struct GroupReference { id: GroupId, relay: RelayUrl, invite: Option } + +impl GroupReference { + pub fn parse(input: &str) -> Result; + pub fn id(&self) -> &GroupId; + pub fn relay(&self) -> &RelayUrl; + pub fn invite(&self) -> Option<&str>; + pub fn key(&self) -> GroupKey; +} + +impl FromStr for GroupReference { .. } // so a pasted naddr can use `.parse()` too + +/// kind:39000, relay-signed. +pub struct GroupMetadata { + pub name: Option, pub picture: Option, pub banner: Option, + pub about: Option, pub private: bool, pub restricted: bool, pub hidden: bool, + pub closed: bool, pub livekit: bool, pub parent: Option, + pub supported_kinds: Option>, // None = unspecified (all); Some(vec![]) = none +} + +impl GroupMetadata { + pub fn parse(event: &Event) -> Result; + pub fn name(&self) -> Option<&str>; + pub fn supports(&self, kind: Kind) -> bool; + pub fn naddr(&self, relay: &RelayUrl, invite: Option<&str>) -> Result; + // Coordinate { kind: 39000, public_key: , identifier: } +} + +/// kind:39001 — each pubkey with its role labels. +pub struct GroupAdmins(Vec<(PublicKey, Vec)>); + +impl GroupAdmins { + pub fn parse(event: &Event) -> Result; + pub fn entries(&self) -> &[(PublicKey, Vec)]; + pub fn contains(&self, public_key: &PublicKey) -> bool; +} + +/// kind:39002 — may be absent or a subset; absence means "not published". +pub struct GroupMembers(Vec); + +impl GroupMembers { + pub fn parse(event: &Event) -> Result; + pub fn contains(&self, public_key: &PublicKey) -> bool; + pub fn iter(&self) -> impl Iterator; +} + +/// kind:39003 — role names are relay-defined. +pub struct GroupRoles(Vec); +pub struct Role { pub name: String, pub description: Option } + +impl GroupRoles { + pub fn parse(event: &Event) -> Result; + pub fn roles(&self) -> &[Role]; +} + +/// kind:10009 — the account's own list. +pub struct GroupList(Vec); +pub struct GroupListEntry { pub id: GroupId, pub relay: RelayUrl, pub name: Option } + +impl GroupList { + pub fn parse(event: &Event) -> Result; + pub fn entries(&self) -> &[GroupListEntry]; + pub fn upsert(&mut self, entry: GroupListEntry); + pub fn remove(&mut self, key: &GroupKey); + pub fn to_builder(&self) -> EventBuilder; // kind 10009, group + r tags +} + +pub enum Membership { + Unknown, + Pending { since: Timestamp }, // our 9021 went out; no answer yet + Member, + Refused { reason: Option }, // the relay's OK false / CLOSED message + Removed, +} + +impl Membership { + /// Folds 9000/9001 only; `Pending` and `Refused` are set by the join path. + pub fn from_events(events: &[Event], me: PublicKey) -> Self; +} + +/// The newest-first window (≤50) that `previous` references are drawn from. +pub struct TimelineWindow(Vec); + +impl TimelineWindow { + pub fn new() -> Self; + pub fn push(&mut self, event: Event); // dedups, caps at 50 + pub fn previous_tag(&self, me: PublicKey) -> Option; // excludes `me`, ≤3 distinct 8-hex +} +``` + +Details that must be right: + +- Every user event carries `["h", id]` (from `GroupId::h_tag`); `previous` is + added only when we have at least one eligible event, and its values are the + first 8 characters of the hex ids. +- `GroupReference::parse` splits `?invite=` before bech32 decoding; a missing + relay hint is an error (there is nowhere to send the join request). +- `GroupList::parse` accepts `group` tags with 1–3 values (id, relay, name). +- `Membership::from_events` takes the newest `9000`/`9001` whose `p` tags name + `me`; on an exact tie it prefers `9001` (removal). `Pending` and `Refused` are + not things the relay folds into an event: the join path sets them from the + `9021` send and from the relay's refusal message. +- `GroupMetadata::supports` distinguishes "tag absent" from "tag present with no + values": the first means all kinds, the second means no text messages. +- The builders return `EventBuilder`s so the caller decides when to sign; nothing + in this file does I/O. + +## Phase 1 — read path (`group.rs` + `lib.rs`) + +`GroupsRegistry` (global entity, mirroring `CommunityRegistry`): + +```rust +pub struct GroupsRegistry { + groups: Vec>, + index: HashMap>, + /// The account's 10009, loaded at start and mutated by join/leave + list: GroupList, + /// subscription id → group, rebuilt whenever a group resubscribes + routes: Arc>>, + signal_tx: flume::Sender, signal_rx: flume::Receiver, + notification_listener: Option>>, // cancelled on signer change + signal_consumer: Option>>, + tasks: SmallVec<[Task>; 2]>, + _subscriptions: SmallVec<[Subscription; 2]>, +} + +impl GroupsRegistry { + pub fn global(cx: &App) -> Entity; + pub fn groups(&self) -> &[Entity]; + pub fn group(&self, key: &GroupKey) -> Option>; + pub fn join(&mut self, reference: GroupReference, cx: &mut Context); + pub fn leave(&mut self, key: &GroupKey, cx: &mut Context); + fn load(&mut self, cx); // own 10009 from the database into `self.list` + fn publish_list(&mut self, cx); // sign `self.list.to_builder()`, to_nip65, best-effort +} +``` + +- **Subscriptions.** One standing REQ per group, two filters in one id: + `key.id().timeline_filter(100)` and `key.id().state_filter()`. The id is + `grp-<8 hex of the group key hash>` (NIP-01 caps ids at 64 chars; the real key + lives in `routes`). The relay is added and connected first + (`add_relay(url).and_connect()`), like `community::sync::connect_relays`. On + open, also issue a one-shot `key.id().membership_filter(me)` fetch so a restart + learns `9000`/`9001` immediately; the state filter is fetched once (limit 1 per + kind) so the sidebar has names before the live REQ settles. A dead relay costs + only its own group. +- **Pump.** The listener matches `ClientNotification::Message` and routes + `RelayMessage::Event`/`EndOfStoredEvents`/`Closed` by subscription id through + `routes`. For `h`-tagged events it saves the envelope first, then sends + `Signal::Event(key, event)`; state events send `Signal::State(key, event)`; + `Closed` sends `Signal::Refused(key, message)`. The consumer calls + `Group::ingest`, which is the only place entity state changes. +- **Envelope cache.** Same shape as `set_rumor`/`get_rumor` in `crates/chat`: + `ApplicationSpecificData` (30078, addressable) with `d` = event id (dedup on + replay), `r` = `"|"` (per-fork history), `k` = kind, content = the + raw event JSON, signed by a crate-local `LOCAL_KEYS`. Every `h`-tagged event + the standing subscription delivers is enveloped — any kind, because the + `previous` window is literally "the last 50 events seen" (NIP-29 timeline + references) — while the `d`-tagged state events (`39000`–`39003`) are not. + Reading is `Filter::new().custom_tag(LOWERCASE_R, key)`, parsed and sorted by + `created_at`, then partitioned: the render set (`1`, `7`, `9`, `11`, `1111`) + becomes messages, `9000`/`9001` fold membership, the rest only feed the + window. This is what makes the timeline fork-safe and restart-safe without + teaching the database about relays. +- **`Group` state.** `GroupKey`, `metadata: Option`, + `metadata_event: Option` (what `GroupMetadata::naddr` is built from), + `members`, `admins`, `roles`, `membership: Membership` (including the local + `Pending`/`Refused` access states), `messages: Vec` for the render set + (reusing `chat::Message`, which already parses any event into display fields), + an id index, a `TimelineWindow` for `previous`, `loading`, `refused`. It emits + `GroupEvent::{Incoming(Event), Updated, Refused(String), Eose}` — the panel + subscribes to these the way `ChatPanel` subscribes to `RoomEvent`. +- **`load()`.** Subscribe to our own `10009` (`ReqTarget::auto`, limit 1), then + read the newest one from the database into `self.list`, and add one group per + entry. A newer `10009` arriving from another device is merged + (`GroupList::upsert`) and the new groups are subscribed; removals by another + client cannot be detected in a replaceable list and are not guessed at. + +## Phase 2 — write path + +All writes sign with the app signer (`finalize_async(&signer)`), send to the +group's relay only, and treat the relay's `OK` as the truth. + +- **Send a message.** Build with + `key.id().message(content, &replies, window.previous_tag(me))`, sign, then + `client.send_event(&event).to([relay])`. Kind 9 is the default message kind; + when `metadata.supports(Kind::ChatMessage)` is false the composer is disabled + rather than sending a kind the relay does not take. On acceptance: save the + envelope, insert into `messages`, emit `GroupEvent::Incoming`. On `OK false`: + return the relay's message (it already carries `invalid:` / `restricted:` / + `duplicate:` prefixes) and let the panel show it. Reactions go through the same + path with `key.id().reaction(..)`. +- **Join.** `GroupReference::parse` → error if no relay hint → add/connect the + relay → fetch `39000` (`limit 1`) to confirm the group and learn the self + pubkey; `GroupMetadata::parse` it; a hidden group may refuse this — proceed + without metadata rather than failing → create the entity, fetch state + + membership, subscribe → send `key.id().join_request(code, ..)` — the `h` tag + plus the `code` tag when an invite code was parsed → mark + `Membership::Pending { since }` and `self.list.upsert(..)`, so the group stays + listed while it waits. The relay either emits `9000` (member), refuses the + `9021` with `OK false` (set `Refused { reason }` from its message — the NIP + says the message explains whether the rejection is final, pending review or + payment-required), or refuses the timeline REQ with `restricted:` / + `auth-required:` in a `CLOSED` (keep the state, do not raise an error toast). + All three are observed on the standing subscription or the send result. +- **Leave.** Send `key.id().leave_request(..)`; on `OK`, forget the group's + envelopes (`client.database().delete(filter)` by `r` tag), drop the entity and + `self.list.remove(key)`, then publish. The relay also emits `9001`, which the + (now gone) subscription would have seen. +- **Publish `10009`.** Sign `self.list.to_builder()` and publish with + `to_nip65()`. The registry's `GroupList` is the one loaded at start, join + `upsert`s an entry and leave `remove`s one, so a rewrite can never drop a group + we loaded. The client saves the event locally even when the publish fails, so + the list survives offline. + +## Phase 3 — later, in rough order + +1. Moderation writes: `9000` put-user (roles), `9001` remove-user, `9002` + edit-metadata, `9005` delete-event, `9009` create-invite; gate the UI on the + caller appearing in `39001`, let the relay be the enforcer. +2. Group creation (`9007`) where the relay supports it, and relay browsing + (`fetch_events(ReqTarget::single(relay, [Filter::new().kind(Kind::GroupMetadata).limit(..)]))`). +3. Pins: `9010` write, `39005` read (`Kind::Custom`); subgroups: parse + `parent`/`child` and lay groups out as a tree. +4. Range backfill: page the timeline with `until` for "load more". +5. LiveKit AV (`livekit` tag, `39004`, the `/.well-known/nip29/livekit` token + endpoint with a NIP-98 `27235` authorization event) and fork/migration + detection through the admins' `10009`. + +## UI later — the hooks this backend leaves + +| UI action | Call | +| --- | --- | +| Paste an naddr and join (or retry a refused join) | `GroupReference::parse` → `GroupsRegistry::join` | +| Open a group | `GroupsRegistry::group(&key, cx)` → panel subscribes to `GroupEvent` | +| Timeline | `Group::messages()` (already `chat::Message`s) | +| Send / react | `Group::send_message` / `send_reaction` | +| Members, admins, roles, flags | `Group::members/admins/roles/metadata` | +| Membership badge (member, pending, refused, removed) | `Group::membership()` | +| Copy an invite link | `GroupMetadata::naddr(relay, invite)` | +| Leave | `GroupsRegistry::leave` | + +Where it lands: the sidebar gains a section reading `GroupsRegistry::groups()` +(the same shape as its Communities section) with pending and refused groups +listed alongside members; a new panel renders `Vec` with the existing +`ListState` + `ui::message::MessageRow` and `PersonRegistry` for authors. The +message *data* is already shared (`chat::Message`), so only `ChatPanel`'s +row-rendering methods need lifting into `ui` (or copying) rather than the type. A +join dialog accepts a pasted naddr (with or without `?invite=`). System +notifications can hang off `GroupEvent::Incoming` when the window is inactive. + +Nothing new is needed from GPUI to support this backend. The local checkouts +are `gpui-pre` 0.3.5 (aliased as `gpui`) and `gpui-base` 0.7.0, which `crates/ui` +styles; the only GPUI APIs the backend uses are the ones `chat` and `community` +already use — `Entity`/`WeakEntity`, `Context::spawn`, `background_spawn`, +`EventEmitter`, `Subscription` — and the panel's components (`ListState`, +`MessageRow`, `Avatar`, `Button`, `Scrollbar`) already exist in the app. + +## Tests + +- `protocol.rs` unit tests: `GroupReference::parse` (naddr, `?invite=`, garbage, + missing relay hint, an naddr whose kind is not `39000`); `GroupMetadata::parse` + flags and `supports` with `supported_kinds` absent vs empty; admins with roles; + members; roles; `GroupList` round trip (`parse` → `upsert` → `to_builder`); + `TimelineWindow::previous_tag` (excludes self, caps at 3, 8-char prefixes, + any-kind window, `None` when empty); `Membership::from_events` (`9000` vs + `9001`, newest wins, tie prefers removal; `Pending`/`Refused` are exercised + through the join path); `GroupId::message` carries `h` and the reply `e` tags; + `GroupMetadata::naddr` round trip with and without an invite code. +- Cache round trip with `nostr-memory` as a dev-dependency (the pattern + `crates/community` already uses): save two envelopes (a kind 9 and a kind + `9000`), read them back sorted, assert the partition — the kind 9 renders, the + `9000` only folds membership — re-save one and confirm no duplicate, `delete` + clears the group. +- Routing: subscription id ↔ `GroupKey` mapping survives a resubscribe. +- One integration test against the SDK's `LocalRelay` + (`nostr-sdk` dev-dependency with the `local-relay` feature): seed a `39000` + and a kind 9 with an `h` tag, run `Group::connect`, assert metadata and the + timeline land. Timeouts in GPUI-driven tests use + `cx.background_executor().timer(..)`, never `smol::Timer`, per `AGENTS.md`. + +## Decisions + +1. **Crate.** `crates/nip29` (the body above is written for it). +2. **Timeline kinds.** Render `1`, `9`, `11`, `1111` and `7`; every other kind + is still cached and feeds the `previous` window, per the NIP's timeline + references rule. +3. **Default message kind.** Kind 9; the composer is disabled when + `supported_kinds` is present and lacks it. +4. **Pending joins.** Pending (and refused) groups are listed in the sidebar, + with an access state on the row. +5. **`10009` merge.** Union-only; removals made by another client are not + detected and no diffing is done. +6. **Leave and the cache.** Leaving wipes the group's envelopes. +7. **Restricted/private groups.** A visible access state is required: + `Membership::{Unknown, Pending, Member, Refused, Removed}` with the relay's + refusal reason shown and a retry, while the client's NIP-42 authenticator + handles the reads. +8. **References.** `naddr` only (`?invite=` accepted); `id@relay` is not. A group + whose relay publishes no `39000` cannot be referenced or joined in v1. -- 2.54.0 From c477246bb8676bf1906c4778b0510d8861c65401 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 30 Sep 2026 08:34:00 +0700 Subject: [PATCH 02/27] init nip29 crate --- Cargo.lock | 8 + crates/nip29/Cargo.toml | 9 + crates/nip29/src/lib.rs | 3 + crates/nip29/src/protocol.rs | 551 +++++++++++++++++++++++++++++++++++ 4 files changed, 571 insertions(+) create mode 100644 crates/nip29/Cargo.toml create mode 100644 crates/nip29/src/lib.rs create mode 100644 crates/nip29/src/protocol.rs diff --git a/Cargo.lock b/Cargo.lock index 64364dd7..8e255ccc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4593,6 +4593,14 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nip29" +version = "1.0.2" +dependencies = [ + "anyhow", + "nostr-sdk", +] + [[package]] name = "no_std_io2" version = "0.9.4" diff --git a/crates/nip29/Cargo.toml b/crates/nip29/Cargo.toml new file mode 100644 index 00000000..8d299d56 --- /dev/null +++ b/crates/nip29/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "nip29" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +nostr-sdk.workspace = true +anyhow.workspace = true diff --git a/crates/nip29/src/lib.rs b/crates/nip29/src/lib.rs new file mode 100644 index 00000000..87f025a7 --- /dev/null +++ b/crates/nip29/src/lib.rs @@ -0,0 +1,3 @@ +mod protocol; + +pub use protocol::*; diff --git a/crates/nip29/src/protocol.rs b/crates/nip29/src/protocol.rs new file mode 100644 index 00000000..45bf54f5 --- /dev/null +++ b/crates/nip29/src/protocol.rs @@ -0,0 +1,551 @@ +use std::collections::hash_map::DefaultHasher; +use std::fmt; +use std::hash::{Hash, Hasher}; +use std::str::FromStr; + +use anyhow::{Result, anyhow, bail}; +use nostr_sdk::prelude::*; + +pub const TIMELINE_WINDOW: usize = 50; + +const PREVIOUS_REFS: usize = 3; +const PREVIOUS_REF_LEN: usize = 8; + +pub const RENDER_KINDS: [Kind; 5] = [ + Kind::TextNote, + Kind::ChatMessage, + Kind::Thread, + Kind::Comment, + Kind::Reaction, +]; + +pub const STATE_KINDS: [Kind; 4] = [ + Kind::GroupMetadata, + Kind::GroupAdmins, + Kind::GroupMembers, + Kind::GroupRoles, +]; + +pub const MEMBERSHIP_KINDS: [Kind; 2] = [Kind::GroupPutUser, Kind::GroupRemoveUser]; + +fn has_tag(tags: &Tags, name: &str) -> bool { + tags.iter().any(|tag| tag.kind() == name) +} + +fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> { + tags.iter() + .find(|tag| tag.kind() == name) + .and_then(|tag| tag.content()) +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct GroupId(String); + +impl GroupId { + pub fn new(id: impl Into) -> Result { + let id = id.into(); + let id = id.trim(); + + if id.is_empty() { + bail!("group id is empty"); + } + + Ok(Self(id.to_owned())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn h_tag(&self) -> Tag { + Tag::custom("h", [self.0.clone()]) + } + + pub fn timeline_filter(&self, limit: usize) -> Filter { + Filter::new() + .custom_tag(SingleLetterTag::LOWERCASE_H, self.0.clone()) + .limit(limit) + } + + pub fn state_filter(&self) -> Filter { + Filter::new().identifier(self.0.clone()).kinds(STATE_KINDS) + } + + pub fn membership_filter(&self, me: PublicKey) -> Filter { + Filter::new() + .custom_tag(SingleLetterTag::LOWERCASE_H, self.0.clone()) + .custom_tag(SingleLetterTag::LOWERCASE_P, me.to_hex()) + .kinds(MEMBERSHIP_KINDS) + } + + pub fn message( + &self, + content: &str, + replies: &[EventId], + previous: Option, + ) -> EventBuilder { + EventBuilder::new(Kind::ChatMessage, content) + .tag(self.h_tag()) + .tags(replies.iter().copied().map(Tag::event)) + .tag_maybe(previous) + } + + pub fn reaction(&self, target: EventId, emoji: &str, previous: Option) -> EventBuilder { + EventBuilder::new(Kind::Reaction, emoji) + .tag(self.h_tag()) + .tag(Tag::event(target)) + .tag_maybe(previous) + } + + pub fn join_request(&self, code: Option<&str>, reason: &str) -> EventBuilder { + EventBuilder::new(Kind::GroupJoinRequest, reason) + .tag(self.h_tag()) + .tag_maybe(code.map(|code| Tag::custom("code", [code]))) + } + + pub fn leave_request(&self, reason: &str) -> EventBuilder { + EventBuilder::new(Kind::GroupLeaveRequest, reason).tag(self.h_tag()) + } +} + +impl fmt::Display for GroupId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct GroupKey { + relay: RelayUrl, + id: GroupId, +} + +impl GroupKey { + pub fn new(relay: RelayUrl, id: GroupId) -> Self { + Self { relay, id } + } + + pub fn relay(&self) -> &RelayUrl { + &self.relay + } + + pub fn id(&self) -> &GroupId { + &self.id + } + + pub fn cache_tag(&self) -> String { + format!("{}|{}", self.relay.as_str(), self.id) + } + + pub fn uniq_id(&self) -> u64 { + let mut hasher = DefaultHasher::new(); + self.hash(&mut hasher); + hasher.finish() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GroupReference { + id: GroupId, + relay: RelayUrl, + invite: Option, +} + +impl GroupReference { + pub fn parse(input: &str) -> Result { + let input = input.trim(); + + let (address, query) = match input.split_once('?') { + Some((address, query)) => (address, Some(query)), + None => (input, None), + }; + + let coordinate = Nip19Coordinate::from_bech32(address)?; + + if coordinate.kind != Kind::GroupMetadata { + bail!("not a group reference: kind {}", coordinate.kind); + } + + let relay = coordinate + .relays + .first() + .cloned() + .ok_or_else(|| anyhow!("group reference has no relay hint"))?; + + let id = GroupId::new(coordinate.identifier.as_str())?; + + let invite = query + .and_then(|query| { + query + .split('&') + .find_map(|pair| pair.strip_prefix("invite=")) + }) + .map(str::to_owned); + + Ok(Self { id, relay, invite }) + } + + pub fn id(&self) -> &GroupId { + &self.id + } + + pub fn relay(&self) -> &RelayUrl { + &self.relay + } + + pub fn invite(&self) -> Option<&str> { + self.invite.as_deref() + } + + pub fn key(&self) -> GroupKey { + GroupKey::new(self.relay.clone(), self.id.clone()) + } +} + +impl FromStr for GroupReference { + type Err = anyhow::Error; + + fn from_str(input: &str) -> Result { + Self::parse(input) + } +} + +#[derive(Debug, Clone)] +pub struct GroupMetadata { + pub name: Option, + pub picture: Option, + pub banner: Option, + pub about: Option, + pub private: bool, + pub restricted: bool, + pub hidden: bool, + pub closed: bool, + pub livekit: bool, + pub parent: Option, + pub supported_kinds: Option>, + id: GroupId, + author: PublicKey, +} + +impl GroupMetadata { + pub fn parse(event: &Event) -> Result { + if event.kind != Kind::GroupMetadata { + bail!("not a group metadata event: kind {}", event.kind); + } + + let identifier = event + .tags + .identifier() + .ok_or_else(|| anyhow!("group metadata has no d tag"))?; + + let mut supported_kinds: Option> = None; + + for tag in event + .tags + .iter() + .filter(|tag| tag.kind() == "supported_kinds") + { + let kinds = supported_kinds.get_or_insert_with(Vec::new); + + for value in tag.as_slice().iter().skip(1) { + kinds.push(Kind::from_str(value)?); + } + } + + let parent = match tag_value(&event.tags, "parent") { + Some(parent) => Some(GroupId::new(parent)?), + None => None, + }; + + Ok(Self { + name: tag_value(&event.tags, "name").map(str::to_owned), + picture: tag_value(&event.tags, "picture").map(str::to_owned), + banner: tag_value(&event.tags, "banner").map(str::to_owned), + about: tag_value(&event.tags, "about").map(str::to_owned), + private: has_tag(&event.tags, "private"), + restricted: has_tag(&event.tags, "restricted"), + hidden: has_tag(&event.tags, "hidden"), + closed: has_tag(&event.tags, "closed"), + livekit: has_tag(&event.tags, "livekit"), + parent, + supported_kinds, + id: GroupId::new(identifier)?, + author: event.pubkey, + }) + } + + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } + + pub fn supports(&self, kind: Kind) -> bool { + match &self.supported_kinds { + None => true, + Some(kinds) => kinds.contains(&kind), + } + } + + pub fn naddr(&self, relay: &RelayUrl, invite: Option<&str>) -> Result { + let coordinate = + Coordinate::new(Kind::GroupMetadata, self.author).identifier(self.id.as_str()); + let address = Nip19Coordinate::new(coordinate, [relay.clone()]).to_bech32()?; + + Ok(match invite { + Some(invite) => format!("{address}?invite={invite}"), + None => address, + }) + } +} + +#[derive(Debug, Clone, Default)] +pub struct GroupAdmins(Vec<(PublicKey, Vec)>); + +impl GroupAdmins { + pub fn parse(event: &Event) -> Result { + if event.kind != Kind::GroupAdmins { + bail!("not a group admins event: kind {}", event.kind); + } + + let mut admins = Vec::new(); + + for tag in event.tags.iter().filter(|tag| tag.kind() == "p") { + let mut fields = tag.as_slice().iter().skip(1); + + let public_key = fields + .next() + .ok_or_else(|| anyhow!("admin tag has no public key"))?; + let public_key = PublicKey::from_hex(public_key)?; + + admins.push((public_key, fields.cloned().collect())); + } + + Ok(Self(admins)) + } + + pub fn entries(&self) -> &[(PublicKey, Vec)] { + &self.0 + } + + pub fn contains(&self, public_key: &PublicKey) -> bool { + self.0.iter().any(|(key, _)| key == public_key) + } +} + +#[derive(Debug, Clone, Default)] +pub struct GroupMembers(Vec); + +impl GroupMembers { + pub fn parse(event: &Event) -> Result { + if event.kind != Kind::GroupMembers { + bail!("not a group members event: kind {}", event.kind); + } + + Ok(Self(event.tags.public_keys().collect())) + } + + pub fn contains(&self, public_key: &PublicKey) -> bool { + self.0.contains(public_key) + } + + pub fn iter(&self) -> impl Iterator { + self.0.iter() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Role { + pub name: String, + pub description: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct GroupRoles(Vec); + +impl GroupRoles { + pub fn parse(event: &Event) -> Result { + if event.kind != Kind::GroupRoles { + bail!("not a group roles event: kind {}", event.kind); + } + + let mut roles = Vec::new(); + + for tag in event.tags.iter().filter(|tag| tag.kind() == "role") { + let mut fields = tag.as_slice().iter().skip(1); + let name = fields + .next() + .ok_or_else(|| anyhow!("role tag has no name"))?; + + roles.push(Role { + name: name.clone(), + description: fields.next().cloned(), + }); + } + + Ok(Self(roles)) + } + + pub fn roles(&self) -> &[Role] { + &self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GroupListEntry { + pub id: GroupId, + pub relay: RelayUrl, + pub name: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct GroupList(Vec); + +impl GroupList { + pub fn parse(event: &Event) -> Result { + if event.kind != Kind::SimpleGroups { + bail!("not a group list event: kind {}", event.kind); + } + + let mut entries = Vec::new(); + + for tag in event.tags.iter().filter(|tag| tag.kind() == "group") { + let fields = tag.as_slice(); + + let (Some(id), Some(relay)) = (fields.get(1), fields.get(2)) else { + continue; + }; + + let Ok(id) = GroupId::new(id.as_str()) else { + continue; + }; + let Ok(relay) = RelayUrl::parse(relay.as_str()) else { + continue; + }; + + entries.push(GroupListEntry { + id, + relay, + name: fields.get(3).cloned(), + }); + } + + Ok(Self(entries)) + } + + pub fn entries(&self) -> &[GroupListEntry] { + &self.0 + } + + pub fn upsert(&mut self, entry: GroupListEntry) { + match self + .0 + .iter_mut() + .find(|existing| existing.id == entry.id && existing.relay == entry.relay) + { + Some(existing) => *existing = entry, + None => self.0.push(entry), + } + } + + pub fn remove(&mut self, key: &GroupKey) { + self.0 + .retain(|entry| entry.id != *key.id() || entry.relay != *key.relay()); + } + + pub fn to_builder(&self) -> EventBuilder { + let mut tags: Vec = Vec::with_capacity(self.0.len() * 2); + + for entry in &self.0 { + let mut values = vec![ + entry.id.as_str().to_owned(), + entry.relay.as_str().to_owned(), + ]; + + if let Some(name) = &entry.name { + values.push(name.clone()); + } + + tags.push(Tag::custom("group", values)); + tags.push(Tag::custom("r", [entry.relay.as_str()])); + } + + EventBuilder::new(Kind::SimpleGroups, "").tags(tags) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Membership { + Unknown, + Pending { since: Timestamp }, + Member, + Refused { reason: Option }, + Removed, +} + +impl Membership { + pub fn from_events(events: &[Event], me: PublicKey) -> Self { + let mut latest: Option<&Event> = None; + + for event in events { + if !MEMBERSHIP_KINDS.contains(&event.kind) { + continue; + } + + if !event.tags.public_keys().any(|key| key == me) { + continue; + } + + let newer = match latest { + None => true, + Some(current) => { + event.created_at > current.created_at + || (event.created_at == current.created_at + && current.kind == Kind::GroupPutUser + && event.kind == Kind::GroupRemoveUser) + } + }; + + if newer { + latest = Some(event); + } + } + + match latest { + Some(event) if event.kind == Kind::GroupRemoveUser => Membership::Removed, + Some(_) => Membership::Member, + None => Membership::Unknown, + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct TimelineWindow(Vec); + +impl TimelineWindow { + pub fn new() -> Self { + Self(Vec::new()) + } + + pub fn push(&mut self, event: Event) { + self.0.retain(|existing| existing.id != event.id); + let position = self.0.partition_point(|e| e.created_at > event.created_at); + + self.0.insert(position, event); + self.0.truncate(TIMELINE_WINDOW); + } + + pub fn previous_tag(&self, me: PublicKey) -> Option { + let references: Vec = self + .0 + .iter() + .filter(|event| event.pubkey != me) + .take(PREVIOUS_REFS) + .map(|event| event.id.to_hex().chars().take(PREVIOUS_REF_LEN).collect()) + .collect(); + + if references.is_empty() { + None + } else { + Some(Tag::custom("previous", references)) + } + } +} -- 2.54.0 From c6f7956dffe5a83d4698bcf966e187ea0a58e964 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 30 Sep 2026 09:23:57 +0700 Subject: [PATCH 03/27] add groups registry --- Cargo.lock | 8 + crates/nip29/Cargo.toml | 8 + crates/nip29/src/group.rs | 323 ++++++++++++++++++++++++++++++++ crates/nip29/src/lib.rs | 377 ++++++++++++++++++++++++++++++++++++++ desktop/Cargo.toml | 1 + desktop/src/main.rs | 3 + web/Cargo.toml | 1 + web/src/lib.rs | 3 + 8 files changed, 724 insertions(+) create mode 100644 crates/nip29/src/group.rs diff --git a/Cargo.lock b/Cargo.lock index 8e255ccc..f1e2cb2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1458,6 +1458,7 @@ dependencies = [ "gpui-pre-reqwest-client", "gpui-pre-windows", "log", + "nip29", "nostr-sdk", "person", "settings", @@ -1487,6 +1488,7 @@ dependencies = [ "gpui-pre-web", "instant", "log", + "nip29", "person", "settings", "state", @@ -4598,7 +4600,13 @@ name = "nip29" version = "1.0.2" dependencies = [ "anyhow", + "chat", + "flume 0.11.1", + "gpui-pre", + "log", "nostr-sdk", + "smallvec", + "state", ] [[package]] diff --git a/crates/nip29/Cargo.toml b/crates/nip29/Cargo.toml index 8d299d56..b69b7874 100644 --- a/crates/nip29/Cargo.toml +++ b/crates/nip29/Cargo.toml @@ -7,3 +7,11 @@ publish.workspace = true [dependencies] nostr-sdk.workspace = true anyhow.workspace = true + +state = { path = "../state" } +chat = { path = "../chat" } + +gpui.workspace = true +flume.workspace = true +log.workspace = true +smallvec.workspace = true diff --git a/crates/nip29/src/group.rs b/crates/nip29/src/group.rs new file mode 100644 index 00000000..76cb7e26 --- /dev/null +++ b/crates/nip29/src/group.rs @@ -0,0 +1,323 @@ +use std::collections::{HashMap, HashSet}; + +use anyhow::Result; +use chat::Message; +use gpui::{App, AppContext, Context, EventEmitter, Task}; +use nostr_sdk::prelude::*; +use smallvec::{SmallVec, smallvec}; +use state::NostrRegistry; + +use crate::LOCAL_KEYS; +use crate::protocol::{ + GroupAdmins, GroupKey, GroupMembers, GroupMetadata, GroupRoles, MEMBERSHIP_KINDS, Membership, + RENDER_KINDS, STATE_KINDS, TimelineWindow, +}; + +const REPLAY_LIMIT: usize = 100; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Absorbed { + Ignored, + Rendered { fresh: bool }, + Changed, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GroupEvent { + Incoming(Event), + Updated, + Eose, + Refused(String), +} + +pub struct Group { + key: GroupKey, + metadata: Option, + members: GroupMembers, + admins: GroupAdmins, + roles: GroupRoles, + membership: Membership, + membership_events: Vec, + state_ids: HashMap, + messages: Vec, + index: HashSet, + window: TimelineWindow, + loading: bool, + refused: Option, + tasks: SmallVec<[Task<()>; 2]>, +} + +impl EventEmitter for Group {} + +impl Group { + pub(crate) fn new(key: GroupKey) -> Self { + Self { + key, + metadata: None, + members: GroupMembers::default(), + admins: GroupAdmins::default(), + roles: GroupRoles::default(), + membership: Membership::Unknown, + membership_events: Vec::new(), + state_ids: HashMap::new(), + messages: Vec::new(), + index: HashSet::new(), + window: TimelineWindow::new(), + loading: true, + refused: None, + tasks: smallvec![], + } + } + + pub fn key(&self) -> &GroupKey { + &self.key + } + + pub fn metadata(&self) -> Option<&GroupMetadata> { + self.metadata.as_ref() + } + + pub fn members(&self) -> &GroupMembers { + &self.members + } + + pub fn admins(&self) -> &GroupAdmins { + &self.admins + } + + pub fn roles(&self) -> &GroupRoles { + &self.roles + } + + pub fn membership(&self) -> &Membership { + &self.membership + } + + pub fn messages(&self) -> &[Message] { + &self.messages + } + + pub fn loading(&self) -> bool { + self.loading + } + + pub fn refused(&self) -> Option<&str> { + self.refused.as_deref() + } + + pub fn previous_tag(&self, me: PublicKey) -> Option { + self.window.previous_tag(me) + } + + pub(crate) fn connect(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let Some(me) = nostr.read(cx).current_user() else { + return; + }; + + let key = self.key.clone(); + let id = crate::subscription_id(&key); + let relay = key.relay().clone(); + + self.tasks.push(cx.background_spawn(async move { + if let Err(error) = client.add_relay(relay.clone()).and_connect().await { + log::warn!("nip29: connecting {relay} failed: {error}"); + } + + let filters = vec![ + key.id().timeline_filter(REPLAY_LIMIT), + key.id().state_filter(), + key.id().membership_filter(me), + ]; + + if let Err(error) = client + .subscribe(ReqTarget::single(relay, filters)) + .with_id(id) + .await + { + log::warn!("nip29: subscribing to {} failed: {error}", key.id()); + } + })); + } + + pub(crate) fn save_envelope(&self, event: Event, cx: &App) -> Task> { + let cache_tag = self.key.cache_tag(); + let nostr = NostrRegistry::global(cx).read(cx); + let client = nostr.client(); + + cx.background_spawn(async move { + let tags = vec![ + Tag::identifier(event.id), + Tag::custom("r", [cache_tag]), + Tag::custom("k", [event.kind.to_string()]), + ]; + + let envelope = EventBuilder::new(Kind::ApplicationSpecificData, event.as_json()) + .tags(tags) + .finalize_async(&*LOCAL_KEYS) + .await?; + + client.database().save_event(&envelope).await?; + + Ok(()) + }) + } + + pub(crate) fn load_envelopes(&self, cx: &App) -> Task>> { + let cache_tag = self.key.cache_tag(); + let nostr = NostrRegistry::global(cx).read(cx); + let client = nostr.client(); + + cx.background_spawn(async move { + let filter = Filter::new() + .kind(Kind::ApplicationSpecificData) + .custom_tag(SingleLetterTag::LOWERCASE_R, cache_tag); + + let mut events: Vec = client + .database() + .query(filter) + .await? + .into_iter() + .filter_map(|envelope| Event::from_json(&envelope.content).ok()) + .collect(); + + events.sort_by_key(|event| event.created_at); + + Ok(events) + }) + } + + pub(crate) fn hydrate(&mut self, events: Vec, me: PublicKey, cx: &mut Context) { + if events.is_empty() { + return; + } + + let mut rendered = Vec::new(); + let mut changed = false; + + for event in &events { + match self.absorb(event, me) { + Absorbed::Rendered { fresh: true } => rendered.push(event.clone()), + Absorbed::Changed => changed = true, + _ => {} + } + } + + for event in rendered { + cx.emit(GroupEvent::Incoming(event)); + } + + if changed { + cx.emit(GroupEvent::Updated); + } + + cx.notify(); + } + + pub(crate) fn eose(&mut self, cx: &mut Context) { + self.loading = false; + cx.emit(GroupEvent::Eose); + cx.notify(); + } + + pub(crate) fn refuse(&mut self, reason: String, cx: &mut Context) { + self.loading = false; + self.refused = Some(reason.clone()); + cx.emit(GroupEvent::Refused(reason)); + cx.notify(); + } + + fn absorb(&mut self, event: &Event, me: PublicKey) -> Absorbed { + if STATE_KINDS.contains(&event.kind) { + return if self.absorb_state(event) { + Absorbed::Changed + } else { + Absorbed::Ignored + }; + } + + if MEMBERSHIP_KINDS.contains(&event.kind) { + self.window.push(event.clone()); + + if !event.tags.public_keys().any(|key| key == me) { + return Absorbed::Ignored; + } + + if !self + .membership_events + .iter() + .any(|existing| existing.id == event.id) + { + self.membership_events.push(event.clone()); + } + + return if self.refresh_membership(me) { + Absorbed::Changed + } else { + Absorbed::Ignored + }; + } + + self.window.push(event.clone()); + + if RENDER_KINDS.contains(&event.kind) { + Absorbed::Rendered { + fresh: self.push_message(event), + } + } else { + Absorbed::Ignored + } + } + + fn absorb_state(&mut self, event: &Event) -> bool { + match event.kind { + Kind::GroupMetadata => match GroupMetadata::parse(event) { + Ok(metadata) => self.metadata = Some(metadata), + Err(_) => return false, + }, + Kind::GroupAdmins => match GroupAdmins::parse(event) { + Ok(admins) => self.admins = admins, + Err(_) => return false, + }, + Kind::GroupMembers => match GroupMembers::parse(event) { + Ok(members) => self.members = members, + Err(_) => return false, + }, + Kind::GroupRoles => match GroupRoles::parse(event) { + Ok(roles) => self.roles = roles, + Err(_) => return false, + }, + _ => return false, + } + + self.state_ids.insert(event.kind, event.id) != Some(event.id) + } + + fn push_message(&mut self, event: &Event) -> bool { + if !self.index.insert(event.id) { + return false; + } + + let message = Message::from(event); + let position = self + .messages + .partition_point(|existing| existing.created_at <= message.created_at); + + self.messages.insert(position, message); + + true + } + + fn refresh_membership(&mut self, me: PublicKey) -> bool { + let derived = Membership::from_events(&self.membership_events, me); + + if derived != Membership::Unknown && derived != self.membership { + self.membership = derived; + return true; + } + + false + } +} diff --git a/crates/nip29/src/lib.rs b/crates/nip29/src/lib.rs index 87f025a7..4946343d 100644 --- a/crates/nip29/src/lib.rs +++ b/crates/nip29/src/lib.rs @@ -1,3 +1,380 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::LazyLock; + +use anyhow::{Error, Result}; +use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task}; +use nostr_sdk::prelude::*; +use smallvec::{SmallVec, smallvec}; +use state::NostrRegistry; + +mod group; mod protocol; +pub use group::*; pub use protocol::*; + +pub(crate) static LOCAL_KEYS: LazyLock = LazyLock::new(Keys::generate); + +const LIST_SUBSCRIPTION: &str = "nip29-list"; +const GROUP_PREFIX: &str = "grp-"; + +pub fn init(cx: &mut App) { + GroupsRegistry::set_global(cx.new(GroupsRegistry::new), cx); +} + +struct GlobalGroupsRegistry(Entity); + +impl Global for GlobalGroupsRegistry {} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GroupsEvent { + Updated, + Error(String), +} + +#[derive(Debug)] +enum Signal { + Event(SubscriptionId, Event), + Eose(SubscriptionId), + Closed(SubscriptionId, String), +} + +fn list_subscription_id() -> SubscriptionId { + SubscriptionId::new(LIST_SUBSCRIPTION) +} + +pub(crate) fn subscription_id(key: &GroupKey) -> SubscriptionId { + SubscriptionId::new(format!("{GROUP_PREFIX}{:08x}", key.uniq_id() as u32)) +} + +fn is_group_subscription(id: &SubscriptionId) -> bool { + let id = id.as_str(); + id == LIST_SUBSCRIPTION || id.starts_with(GROUP_PREFIX) +} + +impl EventEmitter for GroupsRegistry {} + +pub struct GroupsRegistry { + groups: Vec>, + list: GroupList, + routes: HashMap, + signal_tx: flume::Sender, + signal_rx: flume::Receiver, + notification_listener: Option>, + signal_consumer: Option>, + tasks: SmallVec<[Task<()>; 2]>, + _subscriptions: SmallVec<[Subscription; 2]>, +} + +impl GroupsRegistry { + pub fn global(cx: &App) -> Entity { + cx.global::().0.clone() + } + + fn set_global(state: Entity, cx: &mut App) { + cx.set_global(GlobalGroupsRegistry(state)); + } + + fn new(cx: &mut Context) -> Self { + let nostr = NostrRegistry::global(cx); + let this = cx.entity().downgrade(); + + let (tx, rx) = flume::unbounded::(); + let mut subscriptions = smallvec![]; + + subscriptions.push(cx.subscribe(&nostr, |this, _nostr, event, cx| { + if event.signer_changed() { + this.reset(cx); + this.handle_notifications(cx); + this.load(cx); + } + })); + + cx.defer(move |cx| { + if let Err(error) = this.update(cx, |this, cx| { + this.handle_notifications(cx); + + if nostr.read(cx).current_user().is_some() { + this.load(cx); + } + }) { + log::warn!("nip29: initializing the registry failed: {error}"); + } + }); + + Self { + groups: Vec::new(), + list: GroupList::default(), + routes: HashMap::new(), + signal_tx: tx, + signal_rx: rx, + notification_listener: None, + signal_consumer: None, + tasks: smallvec![], + _subscriptions: subscriptions, + } + } + + pub fn groups(&self) -> &[Entity] { + &self.groups + } + + pub fn group(&self, key: &GroupKey, cx: &App) -> Option> { + self.groups + .iter() + .find(|group| group.read(cx).key() == key) + .cloned() + } + + pub fn reset(&mut self, cx: &mut Context) { + self.notification_listener = None; + self.signal_consumer = None; + self.tasks.clear(); + + let routes = std::mem::take(&mut self.routes); + + self.groups.clear(); + self.list = GroupList::default(); + + let client = NostrRegistry::global(cx).read(cx).client(); + + let mut ids: Vec = routes.into_keys().collect(); + ids.push(list_subscription_id()); + + self.tasks.push(cx.background_spawn(async move { + for id in ids { + if let Err(error) = client.unsubscribe(&id).await { + log::warn!("nip29: unsubscribing {id} failed: {error}"); + } + } + })); + + cx.notify(); + } + + fn load(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let signer = nostr.read(cx).signer(); + + let task: Task> = cx.background_spawn(async move { + let me = signer.get_public_key_async().await?; + let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1); + + client + .subscribe(ReqTarget::auto([filter.clone()])) + .with_id(list_subscription_id()) + .await?; + + match client.database().query(filter).await?.into_iter().next() { + Some(event) => GroupList::parse(&event), + None => Ok(GroupList::default()), + } + }); + + self.tasks + .push(cx.spawn(async move |this, cx| match task.await { + Ok(list) => { + if let Err(error) = this.update(cx, |this, cx| this.merge(list, cx)) { + log::warn!("nip29: applying the group list failed: {error}"); + } + } + Err(error) => log::warn!("nip29: loading the group list failed: {error}"), + })); + } + + fn merge(&mut self, list: GroupList, cx: &mut Context) { + let entries = list.entries().to_vec(); + + for entry in entries { + self.list.upsert(entry.clone()); + self.ensure(GroupKey::new(entry.relay.clone(), entry.id.clone()), cx); + } + + cx.emit(GroupsEvent::Updated); + cx.notify(); + } + + fn ensure(&mut self, key: GroupKey, cx: &mut Context) { + if self.group(&key, cx).is_some() { + return; + } + + let group = cx.new(|_| Group::new(key.clone())); + self.groups.push(group); + + self.subscribe(key.clone(), cx); + self.reload(key, None, cx); + } + + fn subscribe(&mut self, key: GroupKey, cx: &mut Context) { + let Some(group) = self.group(&key, cx) else { + return; + }; + + self.routes.insert(subscription_id(&key), key); + + group.update(cx, |group, cx| { + group.connect(cx); + }); + } + + fn reload(&mut self, key: GroupKey, event: Option, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + + let Some(group) = self.group(&key, cx) else { + return; + }; + + let Some(me) = nostr.read(cx).current_user() else { + return; + }; + + let logged = key.clone(); + let load = group.read(cx).load_envelopes(cx); + + self.tasks.push(cx.spawn(async move |_this, cx| { + if let Some(event) = event + && let Err(error) = group + .read_with(cx, |group, cx| group.save_envelope(event, cx)) + .await + { + log::warn!("nip29: caching failed: {error}"); + return; + } + + match load.await { + Ok(events) => { + group.update(cx, |group, cx| { + group.hydrate(events, me, cx); + }); + } + Err(error) => { + log::warn!("nip29: reloading {} failed: {error}", logged.id()); + } + } + })); + } + + fn handle_event(&mut self, id: SubscriptionId, event: Event, cx: &mut Context) { + if id == list_subscription_id() { + match GroupList::parse(&event) { + Ok(list) => self.merge(list, cx), + Err(error) => log::warn!("nip29: ignoring a malformed group list: {error}"), + } + return; + } + + let Some(key) = self.routes.get(&id).cloned() else { + return; + }; + + self.reload(key, Some(event), cx); + } + + fn handle_eose(&mut self, id: SubscriptionId, cx: &mut Context) { + let Some(key) = self.routes.get(&id).cloned() else { + return; + }; + let Some(group) = self.group(&key, cx) else { + return; + }; + + group.update(cx, |group, cx| { + group.eose(cx); + }); + cx.notify(); + } + + fn handle_closed(&mut self, id: SubscriptionId, reason: String, cx: &mut Context) { + let Some(key) = self.routes.get(&id).cloned() else { + return; + }; + let Some(group) = self.group(&key, cx) else { + return; + }; + + group.update(cx, |group, cx| { + group.refuse(reason, cx); + }); + cx.notify(); + } + + fn handle_notifications(&mut self, cx: &mut Context) { + self.notification_listener = None; + self.signal_consumer = None; + + let nostr = NostrRegistry::global(cx).read(cx); + let client = nostr.client(); + + let tx = self.signal_tx.clone(); + let rx = self.signal_rx.clone(); + + self.notification_listener = Some(cx.background_spawn(async move { + let mut notifications = client.notifications(); + let mut processed = HashSet::new(); + const MAX_PROCESSED: usize = 10_000; + + while let Some(notification) = notifications.next().await { + let ClientNotification::Message { message, .. } = notification else { + continue; + }; + + let signal = match *message { + RelayMessage::Event { + subscription_id, + event, + } => { + let id = subscription_id.into_owned(); + + if !is_group_subscription(&id) { + None + } else { + if processed.len() >= MAX_PROCESSED { + processed.clear(); + } + + processed + .insert(event.id) + .then(|| Signal::Event(id, event.into_owned())) + } + } + RelayMessage::EndOfStoredEvents(id) => { + let id = id.into_owned(); + is_group_subscription(&id).then_some(Signal::Eose(id)) + } + RelayMessage::Closed { + subscription_id, + message, + } => { + let id = subscription_id.into_owned(); + is_group_subscription(&id) + .then_some(Signal::Closed(id, message.into_owned())) + } + _ => None, + }; + + if let Some(signal) = signal { + tx.send_async(signal).await.ok(); + } + } + })); + + self.signal_consumer = Some(cx.spawn(async move |this, cx| { + while let Ok(signal) = rx.recv_async().await { + this.update(cx, |this, cx| match signal { + Signal::Event(id, event) => { + this.handle_event(id, event, cx); + } + Signal::Eose(id) => { + this.handle_eose(id, cx); + } + Signal::Closed(id, reason) => { + this.handle_closed(id, reason, cx); + } + }) + .ok(); + } + })); + } +} diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 82568676..79e3baed 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -36,6 +36,7 @@ state = { path = "../crates/state" } device = { path = "../crates/device" } chat = { path = "../crates/chat" } community = { path = "../crates/community" } +nip29 = { path = "../crates/nip29" } 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 a275dc70..43f6d829 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -81,6 +81,9 @@ fn main() { // Initialize community registry community::init(cx); + // Initialize groups registry + nip29::init(cx); + // Initialize auto update auto_update::init(cx); diff --git a/web/Cargo.toml b/web/Cargo.toml index 0f172056..10055154 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -17,6 +17,7 @@ state = { path = "../crates/state" } device = { path = "../crates/device" } chat = { path = "../crates/chat" } community = { path = "../crates/community" } +nip29 = { path = "../crates/nip29" } settings = { path = "../crates/settings" } person = { path = "../crates/person" } diff --git a/web/src/lib.rs b/web/src/lib.rs index d8130655..52774800 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -73,6 +73,9 @@ pub fn run() -> Result<(), JsValue> { // Initialize community registry community::init(cx); + // Initialize groups registry + nip29::init(cx); + cx.open_window(WindowOptions::default(), |window, cx| { cx.new(|cx| Root::new(workspace::init(window, cx).into(), window, cx)) }) -- 2.54.0 From ee9a852a8ffcdbf7078edd6fd91762258d0ee819 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 30 Sep 2026 09:50:49 +0700 Subject: [PATCH 04/27] add send and publish --- crates/community/src/community.rs | 11 +- crates/nip29/src/group.rs | 102 +++++++++++++++- crates/nip29/src/lib.rs | 187 +++++++++++++++++++++++++++++- 3 files changed, 289 insertions(+), 11 deletions(-) diff --git a/crates/community/src/community.rs b/crates/community/src/community.rs index e5cd9a24..4e138493 100644 --- a/crates/community/src/community.rs +++ b/crates/community/src/community.rs @@ -349,7 +349,9 @@ impl Community { } fn start_round(&mut self, channel: ChannelId, intent: Intent, cx: &mut Context) { - let client = NostrRegistry::global(cx).read(cx).client(); + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let held = self.held_keys(&channel); let relays = self.state.relays.clone(); let pages = self.pages.clone(); @@ -498,7 +500,8 @@ impl Community { /// Write the local state document out. fn persist(&mut self, cx: &Context) { - let client = NostrRegistry::global(cx).read(cx).client(); + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); let state = self.state.clone(); let task = cx.background_spawn(async move { @@ -520,7 +523,9 @@ impl Community { limit: usize, cx: &App, ) -> Task> { - let client = NostrRegistry::global(cx).read(cx).client(); + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let channel = *channel; let owner = self.state.owner; let community_id = self.state.id; diff --git a/crates/nip29/src/group.rs b/crates/nip29/src/group.rs index 76cb7e26..8b107da6 100644 --- a/crates/nip29/src/group.rs +++ b/crates/nip29/src/group.rs @@ -1,6 +1,6 @@ use std::collections::{HashMap, HashSet}; -use anyhow::Result; +use anyhow::{Result, anyhow, bail}; use chat::Message; use gpui::{App, AppContext, Context, EventEmitter, Task}; use nostr_sdk::prelude::*; @@ -25,6 +25,7 @@ enum Absorbed { #[derive(Debug, Clone, PartialEq, Eq)] pub enum GroupEvent { Incoming(Event), + Sent(Event), Updated, Eose, Refused(String), @@ -109,6 +110,97 @@ impl Group { self.window.previous_tag(me) } + pub fn send_message( + &mut self, + content: &str, + replies: Vec, + cx: &mut Context, + ) -> Task> { + let nostr = NostrRegistry::global(cx); + + let Some(me) = nostr.read(cx).current_user() else { + return Task::ready(Err(anyhow!("nip29: no signed-in account"))); + }; + + let previous = self.window.previous_tag(me); + let builder = self.key.id().message(content, &replies, previous); + + self.dispatch(builder, cx) + } + + pub fn send_reaction( + &mut self, + target: EventId, + emoji: String, + cx: &mut Context, + ) -> Task> { + let nostr = NostrRegistry::global(cx); + + let Some(me) = nostr.read(cx).current_user() else { + return Task::ready(Err(anyhow!("nip29: no signed-in account"))); + }; + + let previous = self.window.previous_tag(me); + let builder = self.key.id().reaction(target, &emoji, previous); + + self.dispatch(builder, cx) + } + + fn dispatch(&mut self, builder: EventBuilder, cx: &mut Context) -> Task> { + let published = self.publish(builder, cx); + + cx.spawn(async move |this, cx| { + let event = published.await?; + + this.update(cx, |_this, cx| { + cx.emit(GroupEvent::Sent(event)); + })?; + + Ok(()) + }) + } + + pub(crate) fn publish(&self, builder: EventBuilder, cx: &App) -> Task> { + let nostr = NostrRegistry::global(cx).read(cx); + let client = nostr.client(); + let signer = nostr.signer(); + let relay = self.key.relay().clone(); + + cx.background_spawn(async move { + if let Err(error) = client.add_relay(relay.clone()).and_connect().await { + log::warn!("nip29: connecting {relay} failed: {error}"); + } + + let event = builder.finalize_async(&signer).await?; + let output = client.send_event(&event).to([relay]).await?; + + if let Some(reason) = output.failed.into_values().next() { + bail!("{reason}"); + } + + Ok(event) + }) + } + + pub(crate) fn mark_pending(&mut self, since: Timestamp, cx: &mut Context) { + if self.membership != Membership::Member { + self.membership = Membership::Pending { since }; + cx.emit(GroupEvent::Updated); + cx.notify(); + } + } + + pub(crate) fn mark_refused(&mut self, reason: String, cx: &mut Context) { + if self.membership == Membership::Member { + return; + } + self.membership = Membership::Refused { + reason: Some(reason), + }; + cx.emit(GroupEvent::Updated); + cx.notify(); + } + pub(crate) fn connect(&mut self, cx: &mut Context) { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); @@ -144,8 +236,8 @@ impl Group { pub(crate) fn save_envelope(&self, event: Event, cx: &App) -> Task> { let cache_tag = self.key.cache_tag(); - let nostr = NostrRegistry::global(cx).read(cx); - let client = nostr.client(); + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); cx.background_spawn(async move { let tags = vec![ @@ -167,8 +259,8 @@ impl Group { pub(crate) fn load_envelopes(&self, cx: &App) -> Task>> { let cache_tag = self.key.cache_tag(); - let nostr = NostrRegistry::global(cx).read(cx); - let client = nostr.client(); + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); cx.background_spawn(async move { let filter = Filter::new() diff --git a/crates/nip29/src/lib.rs b/crates/nip29/src/lib.rs index 4946343d..f10735fd 100644 --- a/crates/nip29/src/lib.rs +++ b/crates/nip29/src/lib.rs @@ -43,7 +43,7 @@ fn list_subscription_id() -> SubscriptionId { SubscriptionId::new(LIST_SUBSCRIPTION) } -pub(crate) fn subscription_id(key: &GroupKey) -> SubscriptionId { +fn subscription_id(key: &GroupKey) -> SubscriptionId { SubscriptionId::new(format!("{GROUP_PREFIX}{:08x}", key.uniq_id() as u32)) } @@ -58,6 +58,7 @@ pub struct GroupsRegistry { groups: Vec>, list: GroupList, routes: HashMap, + observers: HashMap, signal_tx: flume::Sender, signal_rx: flume::Receiver, notification_listener: Option>, @@ -106,6 +107,7 @@ impl GroupsRegistry { groups: Vec::new(), list: GroupList::default(), routes: HashMap::new(), + observers: HashMap::new(), signal_tx: tx, signal_rx: rx, notification_listener: None, @@ -126,6 +128,67 @@ impl GroupsRegistry { .cloned() } + pub fn join(&mut self, reference: GroupReference, cx: &mut Context) { + let key = reference.key(); + let invite = reference.invite().map(str::to_owned); + + self.ensure(key.clone(), cx); + + let Some(group) = self.group(&key, cx) else { + return; + }; + + self.upsert_entry(&key, None); + self.publish_list(cx); + + cx.emit(GroupsEvent::Updated); + cx.notify(); + + let builder = key.id().join_request(invite.as_deref(), ""); + let sent = group.read(cx).publish(builder, cx); + + group.update(cx, |group, cx| { + group.mark_pending(Timestamp::now(), cx); + }); + + self.tasks.push(cx.spawn(async move |this, cx| { + if let Err(error) = sent.await { + let reason = error.to_string(); + + if let Err(error) = this.update(cx, |this, cx| { + if let Some(group) = this.group(&key, cx) { + group.update(cx, |group, cx| { + group.mark_refused(reason, cx); + }); + } + }) { + log::warn!("nip29: marking the refused join failed: {error}"); + } + } + })); + } + + pub fn leave(&mut self, key: &GroupKey, cx: &mut Context) { + let Some(group) = self.group(key, cx) else { + return; + }; + + let builder = key.id().leave_request(""); + let sent = group.read_with(cx, |group, cx| group.publish(builder, cx)); + let key = key.clone(); + + self.tasks.push(cx.spawn(async move |this, cx| { + if let Err(error) = sent.await { + log::warn!("nip29: leaving {} failed: {error}", key.id()); + return; + } + this.update(cx, |this, cx| { + this.forget(key, cx); + }) + .ok(); + })); + } + pub fn reset(&mut self, cx: &mut Context) { self.notification_listener = None; self.signal_consumer = None; @@ -134,9 +197,11 @@ impl GroupsRegistry { let routes = std::mem::take(&mut self.routes); self.groups.clear(); + self.observers.clear(); self.list = GroupList::default(); - let client = NostrRegistry::global(cx).read(cx).client(); + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); let mut ids: Vec = routes.into_keys().collect(); ids.push(list_subscription_id()); @@ -201,7 +266,21 @@ impl GroupsRegistry { } let group = cx.new(|_| Group::new(key.clone())); - self.groups.push(group); + self.groups.push(group.clone()); + + self.observers.insert( + key.clone(), + cx.subscribe(&group, |this, group, event, cx| match event { + GroupEvent::Sent(event) => { + let key = group.read(cx).key().clone(); + this.reload(key, Some(event.clone()), cx); + } + GroupEvent::Updated => { + this.refresh_entry(group, cx); + } + _ => {} + }), + ); self.subscribe(key.clone(), cx); self.reload(key, None, cx); @@ -219,6 +298,108 @@ impl GroupsRegistry { }); } + fn forget(&mut self, key: GroupKey, cx: &mut Context) { + let id = subscription_id(&key); + let cache_tag = key.cache_tag(); + + self.routes.remove(&id); + self.observers.remove(&key); + self.groups.retain(|group| group.read(cx).key() != &key); + self.list.remove(&key); + + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + self.tasks.push(cx.background_spawn(async move { + if let Err(error) = client.unsubscribe(&id).await { + log::warn!("nip29: unsubscribing {id} failed: {error}"); + } + + let filter = Filter::new() + .kind(Kind::ApplicationSpecificData) + .custom_tag(SingleLetterTag::LOWERCASE_R, cache_tag); + + if let Err(error) = client.database().delete(filter).await { + log::warn!("nip29: clearing the group cache failed: {error}"); + } + })); + + self.publish_list(cx); + + cx.emit(GroupsEvent::Updated); + cx.notify(); + } + + fn entry_name(&self, key: &GroupKey) -> Option { + self.list + .entries() + .iter() + .find(|entry| entry.id == *key.id() && entry.relay == *key.relay()) + .and_then(|entry| entry.name.clone()) + } + + fn upsert_entry(&mut self, key: &GroupKey, name: Option) { + let name = name.or_else(|| self.entry_name(key)); + + self.list.upsert(GroupListEntry { + id: key.id().clone(), + relay: key.relay().clone(), + name, + }); + } + + fn refresh_entry(&mut self, group: Entity, cx: &mut Context) { + let (key, Some(name)) = ({ + let group = group.read(cx); + + ( + group.key().clone(), + group + .metadata() + .and_then(|metadata| metadata.name().map(str::to_owned)), + ) + }) else { + return; + }; + + if self.entry_name(&key).as_deref() == Some(name.as_str()) { + return; + } + + self.upsert_entry(&key, Some(name)); + self.publish_list(cx); + + cx.emit(GroupsEvent::Updated); + cx.notify(); + } + + fn publish_list(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let signer = nostr.read(cx).signer(); + + let builder = self.list.to_builder(); + + self.tasks.push(cx.spawn(async move |_this, _cx| { + let event = match builder.finalize_async(&signer).await { + Ok(event) => event, + Err(error) => { + log::warn!("nip29: signing the group list failed: {error}"); + return; + } + }; + + match client.send_event(&event).to_nip65().await { + Ok(output) if output.failed.is_empty() => {} + Ok(output) => log::warn!( + "nip29: {} relay(s) rejected the group list", + output.failed.len() + ), + Err(error) => log::warn!("nip29: publishing the group list failed: {error}"), + } + })); + } + fn reload(&mut self, key: GroupKey, event: Option, cx: &mut Context) { let nostr = NostrRegistry::global(cx); -- 2.54.0 From de862637f3867c91b9f01f0011b80f034ffd351b Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 30 Sep 2026 12:38:02 +0700 Subject: [PATCH 05/27] add NIP-29 moderation, pins, browse, and create-group support --- crates/nip29/src/group.rs | 77 ++++++++++---- crates/nip29/src/lib.rs | 166 +++++++++++++++++++++++++++-- crates/nip29/src/protocol.rs | 196 +++++++++++++++++++++++++++++++++-- docs/nip29-backend-plan.md | 54 +++++++++- 4 files changed, 452 insertions(+), 41 deletions(-) diff --git a/crates/nip29/src/group.rs b/crates/nip29/src/group.rs index 8b107da6..c0f64d07 100644 --- a/crates/nip29/src/group.rs +++ b/crates/nip29/src/group.rs @@ -9,8 +9,8 @@ use state::NostrRegistry; use crate::LOCAL_KEYS; use crate::protocol::{ - GroupAdmins, GroupKey, GroupMembers, GroupMetadata, GroupRoles, MEMBERSHIP_KINDS, Membership, - RENDER_KINDS, STATE_KINDS, TimelineWindow, + GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata, GroupPins, GroupRoles, + MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS, TimelineWindow, }; const REPLAY_LIMIT: usize = 100; @@ -37,6 +37,7 @@ pub struct Group { members: GroupMembers, admins: GroupAdmins, roles: GroupRoles, + pins: GroupPins, membership: Membership, membership_events: Vec, state_ids: HashMap, @@ -58,6 +59,7 @@ impl Group { members: GroupMembers::default(), admins: GroupAdmins::default(), roles: GroupRoles::default(), + pins: GroupPins::default(), membership: Membership::Unknown, membership_events: Vec::new(), state_ids: HashMap::new(), @@ -90,6 +92,10 @@ impl Group { &self.roles } + pub fn pins(&self) -> &GroupPins { + &self.pins + } + pub fn membership(&self) -> &Membership { &self.membership } @@ -116,16 +122,7 @@ impl Group { replies: Vec, cx: &mut Context, ) -> Task> { - let nostr = NostrRegistry::global(cx); - - let Some(me) = nostr.read(cx).current_user() else { - return Task::ready(Err(anyhow!("nip29: no signed-in account"))); - }; - - let previous = self.window.previous_tag(me); - let builder = self.key.id().message(content, &replies, previous); - - self.dispatch(builder, cx) + self.dispatch(move |id, prev| id.message(content, &replies, prev), cx) } pub fn send_reaction( @@ -134,19 +131,57 @@ impl Group { emoji: String, cx: &mut Context, ) -> Task> { + self.dispatch(move |id, prev| id.reaction(target, &emoji, prev), cx) + } + + pub fn put_user( + &mut self, + public_key: PublicKey, + roles: Vec, + cx: &mut Context, + ) -> Task> { + self.dispatch(move |id, prev| id.put_user(public_key, &roles, prev), cx) + } + + pub fn remove_user( + &mut self, + public_key: PublicKey, + cx: &mut Context, + ) -> Task> { + self.dispatch(move |id, prev| id.remove_user(public_key, prev), cx) + } + + pub fn edit_metadata( + &mut self, + metadata: &GroupMetadata, + cx: &mut Context, + ) -> Task> { + self.dispatch(move |id, prev| id.edit_metadata(metadata, prev), cx) + } + + pub fn delete_event(&mut self, id: EventId, cx: &mut Context) -> Task> { + self.dispatch(move |gid, prev| gid.delete_event(id, prev), cx) + } + + pub fn create_invite(&mut self, code: &str, cx: &mut Context) -> Task> { + self.dispatch(move |id, prev| id.create_invite(code, prev), cx) + } + + pub fn update_pins(&mut self, pins: Vec, cx: &mut Context) -> Task> { + self.dispatch(move |id, prev| id.update_pin_list(&pins, prev), cx) + } + + fn dispatch(&mut self, build: F, cx: &mut Context) -> Task> + where + F: FnOnce(&GroupId, Option) -> EventBuilder, + { let nostr = NostrRegistry::global(cx); let Some(me) = nostr.read(cx).current_user() else { return Task::ready(Err(anyhow!("nip29: no signed-in account"))); }; - let previous = self.window.previous_tag(me); - let builder = self.key.id().reaction(target, &emoji, previous); - - self.dispatch(builder, cx) - } - - fn dispatch(&mut self, builder: EventBuilder, cx: &mut Context) -> Task> { + let builder = build(self.key.id(), self.window.previous_tag(me)); let published = self.publish(builder, cx); cx.spawn(async move |this, cx| { @@ -381,6 +416,10 @@ impl Group { Ok(roles) => self.roles = roles, Err(_) => return false, }, + Kind::Custom(PIN_LIST) => match GroupPins::parse(event) { + Ok(pins) => self.pins = pins, + Err(_) => return false, + }, _ => return false, } diff --git a/crates/nip29/src/lib.rs b/crates/nip29/src/lib.rs index f10735fd..2585aebc 100644 --- a/crates/nip29/src/lib.rs +++ b/crates/nip29/src/lib.rs @@ -1,5 +1,8 @@ +use std::collections::hash_map::DefaultHasher; use std::collections::{HashMap, HashSet}; +use std::hash::{Hash, Hasher}; use std::sync::LazyLock; +use std::time::Duration; use anyhow::{Error, Result}; use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task}; @@ -17,6 +20,9 @@ pub(crate) static LOCAL_KEYS: LazyLock = LazyLock::new(Keys::generate); const LIST_SUBSCRIPTION: &str = "nip29-list"; const GROUP_PREFIX: &str = "grp-"; +const BROWSE_PREFIX: &str = "brw-"; +const BROWSE_LIMIT: usize = 500; +const BROWSE_TIMEOUT: Duration = Duration::from_secs(10); pub fn init(cx: &mut App) { GroupsRegistry::set_global(cx.new(GroupsRegistry::new), cx); @@ -32,6 +38,13 @@ pub enum GroupsEvent { Error(String), } +#[derive(Debug, Clone)] +pub struct Browse { + pub relay: RelayUrl, + pub loading: bool, + pub groups: Vec, +} + #[derive(Debug)] enum Signal { Event(SubscriptionId, Event), @@ -47,9 +60,16 @@ fn subscription_id(key: &GroupKey) -> SubscriptionId { SubscriptionId::new(format!("{GROUP_PREFIX}{:08x}", key.uniq_id() as u32)) } -fn is_group_subscription(id: &SubscriptionId) -> bool { +fn browse_subscription(relay: &RelayUrl) -> SubscriptionId { + let mut hasher = DefaultHasher::new(); + relay.hash(&mut hasher); + + SubscriptionId::new(format!("{BROWSE_PREFIX}{:08x}", hasher.finish() as u32)) +} + +fn is_own_subscription(id: &SubscriptionId) -> bool { let id = id.as_str(); - id == LIST_SUBSCRIPTION || id.starts_with(GROUP_PREFIX) + id == LIST_SUBSCRIPTION || id.starts_with(GROUP_PREFIX) || id.starts_with(BROWSE_PREFIX) } impl EventEmitter for GroupsRegistry {} @@ -57,6 +77,7 @@ impl EventEmitter for GroupsRegistry {} pub struct GroupsRegistry { groups: Vec>, list: GroupList, + browse: HashMap, routes: HashMap, observers: HashMap, signal_tx: flume::Sender, @@ -106,6 +127,7 @@ impl GroupsRegistry { Self { groups: Vec::new(), list: GroupList::default(), + browse: HashMap::new(), routes: HashMap::new(), observers: HashMap::new(), signal_tx: tx, @@ -129,9 +151,10 @@ impl GroupsRegistry { } pub fn join(&mut self, reference: GroupReference, cx: &mut Context) { - let key = reference.key(); - let invite = reference.invite().map(str::to_owned); + self.join_key(reference.key(), reference.invite().map(str::to_owned), cx); + } + pub fn join_key(&mut self, key: GroupKey, invite: Option, cx: &mut Context) { self.ensure(key.clone(), cx); let Some(group) = self.group(&key, cx) else { @@ -168,6 +191,74 @@ impl GroupsRegistry { })); } + pub fn create(&mut self, key: GroupKey, metadata: GroupMetadata, cx: &mut Context) { + self.ensure(key.clone(), cx); + + let Some(group) = self.group(&key, cx) else { + return; + }; + + self.upsert_entry(&key, metadata.name().map(str::to_owned)); + self.publish_list(cx); + + cx.emit(GroupsEvent::Updated); + cx.notify(); + + let builder = key.id().create_group(&metadata, None); + let published = group.read(cx).publish(builder, cx); + let logged = key.clone(); + + self.tasks.push(cx.spawn(async move |this, cx| { + if let Err(e) = published.await { + this.update(cx, |this, cx| { + this.forget(logged, cx); + cx.emit(GroupsEvent::Error(e.to_string())); + }) + .ok(); + } + })); + } + + pub fn browse(&mut self, relay: RelayUrl, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let id = browse_subscription(&relay); + + self.browse.insert( + id.clone(), + Browse { + relay: relay.clone(), + loading: true, + groups: Vec::new(), + }, + ); + + cx.emit(GroupsEvent::Updated); + cx.notify(); + + self.tasks.push(cx.background_spawn(async move { + client.add_relay(relay.clone()).and_connect().await.ok(); + + let filter = Filter::new().kind(Kind::GroupMetadata).limit(BROWSE_LIMIT); + let opts = SubscribeAutoCloseOptions::default() + .exit_policy(ReqExitPolicy::ExitOnEOSE) + .timeout(Some(BROWSE_TIMEOUT)); + + if let Err(error) = client + .subscribe(ReqTarget::single(relay, [filter])) + .with_id(id) + .close_on(opts) + .await + { + log::warn!("nip29: browsing failed: {error}"); + } + })); + } + + pub fn browsed(&self, relay: &RelayUrl) -> Option<&Browse> { + self.browse.get(&browse_subscription(relay)) + } + pub fn leave(&mut self, key: &GroupKey, cx: &mut Context) { let Some(group) = self.group(key, cx) else { return; @@ -205,6 +296,9 @@ impl GroupsRegistry { let mut ids: Vec = routes.into_keys().collect(); ids.push(list_subscription_id()); + ids.extend(self.browse.keys().cloned()); + + self.browse.clear(); self.tasks.push(cx.background_spawn(async move { for id in ids { @@ -446,6 +540,11 @@ impl GroupsRegistry { return; } + if self.browse.contains_key(&id) { + self.absorb_candidate(&id, &event, cx); + return; + } + let Some(key) = self.routes.get(&id).cloned() else { return; }; @@ -453,10 +552,49 @@ impl GroupsRegistry { self.reload(key, Some(event), cx); } + fn absorb_candidate(&mut self, id: &SubscriptionId, event: &Event, cx: &mut Context) { + let Some(browse) = self.browse.get_mut(id) else { + return; + }; + + let Ok(metadata) = GroupMetadata::parse(event) else { + log::warn!( + "nip29: ignoring malformed group metadata from {}", + browse.relay + ); + return; + }; + + let Some(group) = metadata.id().cloned() else { + return; + }; + + let candidate = GroupCandidate { + key: GroupKey::new(browse.relay.clone(), group), + metadata, + }; + + match browse.groups.iter_mut().find(|e| e.key == candidate.key) { + Some(existing) => *existing = candidate, + None => browse.groups.push(candidate), + } + + cx.emit(GroupsEvent::Updated); + cx.notify(); + } + fn handle_eose(&mut self, id: SubscriptionId, cx: &mut Context) { + if let Some(browse) = self.browse.get_mut(&id) { + browse.loading = false; + cx.emit(GroupsEvent::Updated); + cx.notify(); + return; + } + let Some(key) = self.routes.get(&id).cloned() else { return; }; + let Some(group) = self.group(&key, cx) else { return; }; @@ -464,13 +602,24 @@ impl GroupsRegistry { group.update(cx, |group, cx| { group.eose(cx); }); + cx.notify(); } fn handle_closed(&mut self, id: SubscriptionId, reason: String, cx: &mut Context) { + if let Some(browse) = self.browse.get_mut(&id) { + log::warn!("nip29: browsing {} was refused: {reason}", browse.relay); + browse.loading = false; + + cx.emit(GroupsEvent::Updated); + cx.notify(); + return; + } + let Some(key) = self.routes.get(&id).cloned() else { return; }; + let Some(group) = self.group(&key, cx) else { return; }; @@ -478,6 +627,7 @@ impl GroupsRegistry { group.update(cx, |group, cx| { group.refuse(reason, cx); }); + cx.notify(); } @@ -508,13 +658,12 @@ impl GroupsRegistry { } => { let id = subscription_id.into_owned(); - if !is_group_subscription(&id) { + if !is_own_subscription(&id) { None } else { if processed.len() >= MAX_PROCESSED { processed.clear(); } - processed .insert(event.id) .then(|| Signal::Event(id, event.into_owned())) @@ -522,15 +671,14 @@ impl GroupsRegistry { } RelayMessage::EndOfStoredEvents(id) => { let id = id.into_owned(); - is_group_subscription(&id).then_some(Signal::Eose(id)) + is_own_subscription(&id).then_some(Signal::Eose(id)) } RelayMessage::Closed { subscription_id, message, } => { let id = subscription_id.into_owned(); - is_group_subscription(&id) - .then_some(Signal::Closed(id, message.into_owned())) + is_own_subscription(&id).then_some(Signal::Closed(id, message.into_owned())) } _ => None, }; diff --git a/crates/nip29/src/protocol.rs b/crates/nip29/src/protocol.rs index 45bf54f5..2e1bbcc4 100644 --- a/crates/nip29/src/protocol.rs +++ b/crates/nip29/src/protocol.rs @@ -19,11 +19,15 @@ pub const RENDER_KINDS: [Kind; 5] = [ Kind::Reaction, ]; -pub const STATE_KINDS: [Kind; 4] = [ +pub const PIN_LIST: u16 = 39_005; +pub const UPDATE_PIN_LIST: u16 = 9_010; + +pub const STATE_KINDS: [Kind; 5] = [ Kind::GroupMetadata, Kind::GroupAdmins, Kind::GroupMembers, Kind::GroupRoles, + Kind::Custom(PIN_LIST), ]; pub const MEMBERSHIP_KINDS: [Kind; 2] = [Kind::GroupPutUser, Kind::GroupRemoveUser]; @@ -106,6 +110,106 @@ impl GroupId { pub fn leave_request(&self, reason: &str) -> EventBuilder { EventBuilder::new(Kind::GroupLeaveRequest, reason).tag(self.h_tag()) } + + pub fn put_user( + &self, + public_key: PublicKey, + roles: &[String], + previous: Option, + ) -> EventBuilder { + let mut values = Vec::with_capacity(1 + roles.len()); + values.push(public_key.to_hex()); + values.extend(roles.iter().cloned()); + + EventBuilder::new(Kind::GroupPutUser, "") + .tag(self.h_tag()) + .tag(Tag::custom("p", values)) + .tag_maybe(previous) + } + + pub fn remove_user(&self, public_key: PublicKey, previous: Option) -> EventBuilder { + EventBuilder::new(Kind::GroupRemoveUser, "") + .tag(self.h_tag()) + .tag(Tag::public_key(public_key)) + .tag_maybe(previous) + } + + pub fn edit_metadata(&self, metadata: &GroupMetadata, previous: Option) -> EventBuilder { + EventBuilder::new(Kind::GroupEditMetadata, "") + .tags(self.metadata_tags(metadata)) + .tag_maybe(previous) + } + + pub fn create_group(&self, metadata: &GroupMetadata, previous: Option) -> EventBuilder { + EventBuilder::new(Kind::GroupCreateGroup, "") + .tags(self.metadata_tags(metadata)) + .tag_maybe(previous) + } + + fn metadata_tags(&self, metadata: &GroupMetadata) -> Vec { + let mut tags = vec![self.h_tag()]; + + for (name, value) in [ + ("name", &metadata.name), + ("picture", &metadata.picture), + ("banner", &metadata.banner), + ("about", &metadata.about), + ] { + if let Some(value) = value { + tags.push(Tag::custom(name, [value.clone()])); + } + } + + for (name, set) in [ + ("private", metadata.private), + ("restricted", metadata.restricted), + ("hidden", metadata.hidden), + ("closed", metadata.closed), + ("livekit", metadata.livekit), + ] { + if set { + tags.push(Tag::custom(name, Vec::::new())); + } + } + + if let Some(parent) = &metadata.parent { + tags.push(Tag::custom("parent", [parent.as_str()])); + } + + for child in &metadata.children { + tags.push(Tag::custom("child", [child.as_str()])); + } + + if let Some(kinds) = &metadata.supported_kinds { + tags.push(Tag::custom( + "supported_kinds", + kinds.iter().map(ToString::to_string), + )); + } + + tags + } + + pub fn delete_event(&self, id: EventId, previous: Option) -> EventBuilder { + EventBuilder::new(Kind::GroupDeleteEvent, "") + .tag(self.h_tag()) + .tag(Tag::event(id)) + .tag_maybe(previous) + } + + pub fn create_invite(&self, code: &str, previous: Option) -> EventBuilder { + EventBuilder::new(Kind::GroupCreateInvite, "") + .tag(self.h_tag()) + .tag(Tag::custom("code", [code])) + .tag_maybe(previous) + } + + pub fn update_pin_list(&self, pins: &[Pin], previous: Option) -> EventBuilder { + EventBuilder::new(Kind::Custom(UPDATE_PIN_LIST), "") + .tag(self.h_tag()) + .tags(pins.iter().map(Pin::tag)) + .tag_maybe(previous) + } } impl fmt::Display for GroupId { @@ -210,7 +314,7 @@ impl FromStr for GroupReference { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct GroupMetadata { pub name: Option, pub picture: Option, @@ -222,9 +326,10 @@ pub struct GroupMetadata { pub closed: bool, pub livekit: bool, pub parent: Option, + pub children: Vec, pub supported_kinds: Option>, - id: GroupId, - author: PublicKey, + id: Option, + author: Option, } impl GroupMetadata { @@ -257,6 +362,14 @@ impl GroupMetadata { None => None, }; + let mut children = Vec::new(); + + for tag in event.tags.iter().filter(|tag| tag.kind() == "child") { + if let Some(child) = tag.content() { + children.push(GroupId::new(child)?); + } + } + Ok(Self { name: tag_value(&event.tags, "name").map(str::to_owned), picture: tag_value(&event.tags, "picture").map(str::to_owned), @@ -268,12 +381,17 @@ impl GroupMetadata { closed: has_tag(&event.tags, "closed"), livekit: has_tag(&event.tags, "livekit"), parent, + children, supported_kinds, - id: GroupId::new(identifier)?, - author: event.pubkey, + id: Some(GroupId::new(identifier)?), + author: Some(event.pubkey), }) } + pub(crate) fn id(&self) -> Option<&GroupId> { + self.id.as_ref() + } + pub fn name(&self) -> Option<&str> { self.name.as_deref() } @@ -286,8 +404,10 @@ impl GroupMetadata { } pub fn naddr(&self, relay: &RelayUrl, invite: Option<&str>) -> Result { - let coordinate = - Coordinate::new(Kind::GroupMetadata, self.author).identifier(self.id.as_str()); + let id = self.id.as_ref().ok_or_else(|| anyhow!("no id"))?; + let author = self.author.ok_or_else(|| anyhow!("no author"))?; + + let coordinate = Coordinate::new(Kind::GroupMetadata, author).identifier(id.as_str()); let address = Nip19Coordinate::new(coordinate, [relay.clone()]).to_bech32()?; Ok(match invite { @@ -297,6 +417,12 @@ impl GroupMetadata { } } +#[derive(Debug, Clone)] +pub struct GroupCandidate { + pub key: GroupKey, + pub metadata: GroupMetadata, +} + #[derive(Debug, Clone, Default)] pub struct GroupAdmins(Vec<(PublicKey, Vec)>); @@ -389,6 +515,60 @@ impl GroupRoles { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Pin { + Event(EventId), + Address(Coordinate), +} + +impl Pin { + fn tag(&self) -> Tag { + match self { + Pin::Event(id) => Tag::event(*id), + Pin::Address(coordinate) => Tag::coordinate(coordinate.clone(), None), + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct GroupPins(Vec); + +impl GroupPins { + pub fn parse(event: &Event) -> Result { + if event.kind != Kind::Custom(PIN_LIST) { + bail!("not a pinned list event: kind {}", event.kind); + } + + let mut pins = Vec::new(); + + for tag in event.tags.iter() { + match tag.kind() { + "e" => { + let Some(id) = tag.content() else { + continue; + }; + + pins.push(Pin::Event(EventId::from_hex(id)?)); + } + "a" => { + let Some(address) = tag.content() else { + continue; + }; + + pins.push(Pin::Address(Coordinate::from_str(address)?)); + } + _ => {} + } + } + + Ok(Self(pins)) + } + + pub fn pins(&self) -> &[Pin] { + &self.0 + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct GroupListEntry { pub id: GroupId, diff --git a/docs/nip29-backend-plan.md b/docs/nip29-backend-plan.md index c967da00..be8bbee8 100644 --- a/docs/nip29-backend-plan.md +++ b/docs/nip29-backend-plan.md @@ -393,11 +393,50 @@ group's relay only, and treat the relay's `OK` as the truth. 1. Moderation writes: `9000` put-user (roles), `9001` remove-user, `9002` edit-metadata, `9005` delete-event, `9009` create-invite; gate the UI on the - caller appearing in `39001`, let the relay be the enforcer. -2. Group creation (`9007`) where the relay supports it, and relay browsing - (`fetch_events(ReqTarget::single(relay, [Filter::new().kind(Kind::GroupMetadata).limit(..)]))`). -3. Pins: `9010` write, `39005` read (`Kind::Custom`); subgroups: parse - `parent`/`child` and lay groups out as a tree. + caller appearing in `39001`, let the relay be the enforcer. **Done** as pure + writes: builders on `GroupId`, senders on `Group` sharing `dispatch`. + `edit_metadata` takes a `GroupMetadata`, which is now `Default`-able (its + private `id`/`author` origin are `Option`) so the UI can start an edit from + scratch or from `Group::metadata()`. **`9005` is write-only for now**: the + relay stops serving the event but nothing is removed locally, so the deleted + message lingers in the cache and timeline. The UI phase must drop the target + envelope (filter on its `d` = target id and the group's `r` cache tag) and + evict it from `messages`/`index`, otherwise only a reload clears it. +2. Group creation (`9007`) where the relay supports it, and relay browsing. + **Done.** `GroupId::create_group` reuses the `9002` metadata tags, so the + proposed id (`h`) and the initial name/flags go in one event; the relay + republishes `39000`, which the standing subscription picks up. + `GroupsRegistry::create` mirrors `join`: `ensure` → optimistic `10009` entry + + `publish_list` → publish, and on `OK false` it `forget`s the group and emits + `GroupsEvent::Error(reason)` (that variant was previously unused). There is + no NIP-11 support check available, so "where the relay supports it" is + enforced by the relay refusing the `9007`. + `GroupsRegistry::browse(relay, cx)` follows the same pipeline as every + other read: it registers a `brw-` subscription (an opaque id, like + `grp-`; the relay is keyed in state, not encoded in the id, because the + pump needs a prefix to recognise its own ids and a bare + `SubscriptionId::generate()` would be indistinguishable from the other + crates' ids on the shared client). `add_relay` + + `subscribe` (auto-close on EOSE, 10s cap), and returns immediately. The + **global pump** observes the events, and `handle_event`/`handle_eose`/ + `handle_closed` fold each relay-signed `39000` into `self.browse`, keyed by + the subscription id, so no DB scoping is needed. `GroupsRegistry::browsed(relay)` + returns `Browse { relay, loading, groups: Vec }` for the UI, and + `GroupsEvent::Updated` says when to re-read it. No `fetch_events` anywhere: + the DB cannot be scoped to a relay (a stored event carries no origin), so the + subscription's own events are the source. `join` now delegates to a new + `join_key(key, invite)`, which is what a browse result needs (a candidate has + a key, not a `GroupReference`). +3. Pins and subgroups. **Pins done:** `Pin::{Event, Address}` and + `GroupPins` in `protocol.rs`, with `PIN_LIST` (39005) and `UPDATE_PIN_LIST` + (9010) as `u16` consts used through `Kind::Custom`. `39005` joins + `STATE_KINDS`, so the standing subscription fetches it, `absorb_state` folds + it into `Group::pins()`, and `Group::update_pins(pins)` writes `9010` through + the shared `dispatch`. **Subgroups parsed:** `GroupMetadata` gained + `pub children: Vec` (order preserved, parsed from `child` tags) and + `metadata_tags` re-emits them — `9002` requires it, an edit that drops a + child is rejected. `parent`/`children` are per-relay, so assembling the tree + from `Group::metadata()` is UI work; there is no backend tree helper. 4. Range backfill: page the timeline with `until` for "load more". 5. LiveKit AV (`livekit` tag, `39004`, the `/.well-known/nip29/livekit` token endpoint with a NIP-98 `27235` authorization event) and fork/migration @@ -415,6 +454,11 @@ group's relay only, and treat the relay's `OK` as the truth. | Membership badge (member, pending, refused, removed) | `Group::membership()` | | Copy an invite link | `GroupMetadata::naddr(relay, invite)` | | Leave | `GroupsRegistry::leave` | +| Create a group | `GroupMetadata { ..Default::default() }` → `GroupsRegistry::create(key, metadata, cx)`; failure surfaces as `GroupsEvent::Error` | +| Browse a relay's groups | `GroupsRegistry::browse(relay, cx)` → re-read `GroupsRegistry::browsed(relay)` on `GroupsEvent::Updated`; join a result with `GroupsRegistry::join_key(candidate.key, None, cx)` | +| Moderate (put/remove user, edit metadata, delete event, invite) | `Group::put_user/remove_user/edit_metadata/delete_event/create_invite` | +| Pins | `Group::pins()` to read, `Group::update_pins(pins)` to replace the list | +| Subgroup tree | `Group::metadata()` → `parent`/`children`, assembled per relay | Where it lands: the sidebar gains a section reading `GroupsRegistry::groups()` (the same shape as its Communities section) with pending and refused groups -- 2.54.0 From d621b125a967edf1f3bb4c455a6a7b5954a176c9 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 30 Sep 2026 12:49:37 +0700 Subject: [PATCH 06/27] add timeline paging for older messages --- crates/nip29/src/group.rs | 66 ++++++++++++++++++++++++++++++++++-- crates/nip29/src/lib.rs | 66 ++++++++++++++++++++++++++++++++++-- crates/nip29/src/protocol.rs | 4 +++ docs/nip29-backend-plan.md | 34 ++++++++++++++++++- 4 files changed, 164 insertions(+), 6 deletions(-) diff --git a/crates/nip29/src/group.rs b/crates/nip29/src/group.rs index c0f64d07..c15a8f40 100644 --- a/crates/nip29/src/group.rs +++ b/crates/nip29/src/group.rs @@ -1,4 +1,5 @@ use std::collections::{HashMap, HashSet}; +use std::time::Duration; use anyhow::{Result, anyhow, bail}; use chat::Message; @@ -14,6 +15,8 @@ use crate::protocol::{ }; const REPLAY_LIMIT: usize = 100; +const PAGE_LIMIT: usize = 50; +const PAGE_TIMEOUT: Duration = Duration::from_secs(10); #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Absorbed { @@ -44,7 +47,9 @@ pub struct Group { messages: Vec, index: HashSet, window: TimelineWindow, + oldest: Option, loading: bool, + loading_more: bool, refused: Option, tasks: SmallVec<[Task<()>; 2]>, } @@ -66,7 +71,9 @@ impl Group { messages: Vec::new(), index: HashSet::new(), window: TimelineWindow::new(), + oldest: None, loading: true, + loading_more: false, refused: None, tasks: smallvec![], } @@ -108,6 +115,14 @@ impl Group { self.loading } + pub fn loading_more(&self) -> bool { + self.loading_more + } + + pub fn oldest(&self) -> Option { + self.oldest + } + pub fn refused(&self) -> Option<&str> { self.refused.as_deref() } @@ -269,6 +284,45 @@ impl Group { })); } + pub(crate) fn load_page(&mut self, cx: &mut Context) { + let Some(until) = self.oldest else { + return; + }; + + self.loading_more = true; + cx.notify(); + + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let id = crate::page_subscription(&self.key); + let relay = self.key.relay().clone(); + let filter = self.key.id().timeline_page(PAGE_LIMIT, until); + + self.tasks.push(cx.background_spawn(async move { + client.add_relay(relay.clone()).and_connect().await.ok(); + + let opts = SubscribeAutoCloseOptions::default() + .exit_policy(ReqExitPolicy::ExitOnEOSE) + .timeout(Some(PAGE_TIMEOUT)); + + if let Err(error) = client + .subscribe(ReqTarget::single(relay, [filter])) + .with_id(id) + .close_on(opts) + .await + { + log::warn!("nip29: loading older messages failed: {error}"); + } + })); + } + + pub(crate) fn page_finished(&mut self, cx: &mut Context) { + self.loading_more = false; + cx.emit(GroupEvent::Updated); + cx.notify(); + } + pub(crate) fn save_envelope(&self, event: Event, cx: &App) -> Task> { let cache_tag = self.key.cache_tag(); let nostr = NostrRegistry::global(cx); @@ -366,7 +420,7 @@ impl Group { } if MEMBERSHIP_KINDS.contains(&event.kind) { - self.window.push(event.clone()); + self.track(event); if !event.tags.public_keys().any(|key| key == me) { return Absorbed::Ignored; @@ -387,7 +441,7 @@ impl Group { }; } - self.window.push(event.clone()); + self.track(event); if RENDER_KINDS.contains(&event.kind) { Absorbed::Rendered { @@ -398,6 +452,14 @@ impl Group { } } + fn track(&mut self, event: &Event) { + self.window.push(event.clone()); + + if self.oldest.is_none_or(|oldest| event.created_at < oldest) { + self.oldest = Some(event.created_at); + } + } + fn absorb_state(&mut self, event: &Event) -> bool { match event.kind { Kind::GroupMetadata => match GroupMetadata::parse(event) { diff --git a/crates/nip29/src/lib.rs b/crates/nip29/src/lib.rs index 2585aebc..17ab70ba 100644 --- a/crates/nip29/src/lib.rs +++ b/crates/nip29/src/lib.rs @@ -21,6 +21,7 @@ pub(crate) static LOCAL_KEYS: LazyLock = LazyLock::new(Keys::generate); const LIST_SUBSCRIPTION: &str = "nip29-list"; const GROUP_PREFIX: &str = "grp-"; const BROWSE_PREFIX: &str = "brw-"; +const PAGE_PREFIX: &str = "pg-"; const BROWSE_LIMIT: usize = 500; const BROWSE_TIMEOUT: Duration = Duration::from_secs(10); @@ -67,9 +68,20 @@ fn browse_subscription(relay: &RelayUrl) -> SubscriptionId { SubscriptionId::new(format!("{BROWSE_PREFIX}{:08x}", hasher.finish() as u32)) } +fn page_subscription(key: &GroupKey) -> SubscriptionId { + SubscriptionId::new(format!("{PAGE_PREFIX}{:08x}", key.uniq_id() as u32)) +} + +fn is_page(id: &SubscriptionId) -> bool { + id.as_str().starts_with(PAGE_PREFIX) +} + fn is_own_subscription(id: &SubscriptionId) -> bool { let id = id.as_str(); - id == LIST_SUBSCRIPTION || id.starts_with(GROUP_PREFIX) || id.starts_with(BROWSE_PREFIX) + id == LIST_SUBSCRIPTION + || id.starts_with(GROUP_PREFIX) + || id.starts_with(BROWSE_PREFIX) + || id.starts_with(PAGE_PREFIX) } impl EventEmitter for GroupsRegistry {} @@ -259,6 +271,22 @@ impl GroupsRegistry { self.browse.get(&browse_subscription(relay)) } + pub fn load_more(&mut self, key: &GroupKey, cx: &mut Context) { + let Some(group) = self.group(key, cx) else { + return; + }; + + if group.read(cx).loading_more() || group.read(cx).oldest().is_none() { + return; + } + + self.routes.insert(page_subscription(key), key.clone()); + + group.update(cx, |group, cx| { + group.load_page(cx); + }); + } + pub fn leave(&mut self, key: &GroupKey, cx: &mut Context) { let Some(group) = self.group(key, cx) else { return; @@ -394,9 +422,11 @@ impl GroupsRegistry { fn forget(&mut self, key: GroupKey, cx: &mut Context) { let id = subscription_id(&key); + let page = page_subscription(&key); let cache_tag = key.cache_tag(); self.routes.remove(&id); + self.routes.remove(&page); self.observers.remove(&key); self.groups.retain(|group| group.read(cx).key() != &key); self.list.remove(&key); @@ -405,8 +435,10 @@ impl GroupsRegistry { let client = nostr.read(cx).client(); self.tasks.push(cx.background_spawn(async move { - if let Err(error) = client.unsubscribe(&id).await { - log::warn!("nip29: unsubscribing {id} failed: {error}"); + for id in [id, page] { + if let Err(error) = client.unsubscribe(&id).await { + log::warn!("nip29: unsubscribing {id} failed: {error}"); + } } let filter = Filter::new() @@ -595,6 +627,18 @@ impl GroupsRegistry { return; }; + if is_page(&id) { + self.routes.remove(&id); + + if let Some(group) = self.group(&key, cx) { + group.update(cx, |group, cx| { + group.page_finished(cx); + }); + } + + return; + } + let Some(group) = self.group(&key, cx) else { return; }; @@ -620,6 +664,22 @@ impl GroupsRegistry { return; }; + if is_page(&id) { + self.routes.remove(&id); + log::warn!( + "nip29: loading older messages in {} was refused: {reason}", + key.id() + ); + + if let Some(group) = self.group(&key, cx) { + group.update(cx, |group, cx| { + group.page_finished(cx); + }); + } + + return; + } + let Some(group) = self.group(&key, cx) else { return; }; diff --git a/crates/nip29/src/protocol.rs b/crates/nip29/src/protocol.rs index 2e1bbcc4..fbf82431 100644 --- a/crates/nip29/src/protocol.rs +++ b/crates/nip29/src/protocol.rs @@ -71,6 +71,10 @@ impl GroupId { .limit(limit) } + pub fn timeline_page(&self, limit: usize, until: Timestamp) -> Filter { + self.timeline_filter(limit).until(until) + } + pub fn state_filter(&self) -> Filter { Filter::new().identifier(self.0.clone()).kinds(STATE_KINDS) } diff --git a/docs/nip29-backend-plan.md b/docs/nip29-backend-plan.md index be8bbee8..e592e28a 100644 --- a/docs/nip29-backend-plan.md +++ b/docs/nip29-backend-plan.md @@ -136,6 +136,12 @@ GroupsRegistry ──ensure relay──► Group entity ◄──ingest── ba UI later: GroupPanel (Vec), JoinDialog (naddr) ``` +A group's subscriptions are the standing `grp-` REQ (timeline + state + +membership, the timeline replayed up to 100 events) and, on demand, a +`pg-` REQ that re-asks the same timeline filter with `until` set to the +oldest event hydrated so far. Both are recognised by the same pump and land in +the same envelope cache, so paging needs no separate read path. + ## Phase 0 — `crates/nip29/src/protocol.rs` (pure, no I/O) The protocol is expressed as types with `impl` blocks: a parsed thing is a struct @@ -437,7 +443,32 @@ group's relay only, and treat the relay's `OK` as the truth. `metadata_tags` re-emits them — `9002` requires it, an edit that drops a child is rejected. `parent`/`children` are per-relay, so assembling the tree from `Group::metadata()` is UI work; there is no backend tree helper. -4. Range backfill: page the timeline with `until` for "load more". +4. Range backfill: page the timeline with `until` for "load more". **Done.** + `GroupId::timeline_page(limit, until)` is `timeline_filter` plus `.until(..)`, + and `Group::load_page` sends it as a `pg-` subscription (auto-close on + EOSE, 10s cap) alongside the standing `grp-` one, which keeps running so + paging cannot replace the live tail. The read path is the usual one: `pg-` + joins `is_own_subscription`, the id is registered in `routes` against the same + key, and `handle_event` therefore caches each page event and `reload`s the + group, so it arrives through the envelope cache and the timeline like any + other event. `GroupsRegistry::load_more(key, cx)` is the entry point: it does + nothing while a page is in flight (`Group::loading_more`) or before anything is + cached, and registers the route before `Group::load_page` sets the flag. + `handle_eose` and a page `CLOSED` remove the route and call + `Group::page_finished`, which clears the flag and emits `GroupEvent::Updated` + so a panel's spinner can settle. The cursor is `Group::oldest()`, the oldest + `h`-tagged event ever hydrated, updated by `track` next to `window.push` — + *not* read from `TimelineWindow`, which keeps only the newest 50 events, so a + cursor taken from it would never move and would page the same window forever. + `until` is inclusive, so a page repeats the event it starts from; `hydrate` + dedups by id, so that costs one redundant fetch, never a gap. `forget` also + drops and unsubscribes the `pg-` route (`reset` already sweeps `routes`). Two + gaps left for the UI phase: a relay that accepts the REQ but never EOSEs leaves + `loading_more` true (the auto-close timeout closes the subscription locally and + emits no notification — the same stickiness as `Browse::loading`), and a group + whose next page shares the `until` second cannot advance the cursor, so it can + re-fetch the same page. Neither loops on its own; both need a "load older + messages" button bound to `Group::loading_more()` to be observable at all. 5. LiveKit AV (`livekit` tag, `39004`, the `/.well-known/nip29/livekit` token endpoint with a NIP-98 `27235` authorization event) and fork/migration detection through the admins' `10009`. @@ -449,6 +480,7 @@ group's relay only, and treat the relay's `OK` as the truth. | Paste an naddr and join (or retry a refused join) | `GroupReference::parse` → `GroupsRegistry::join` | | Open a group | `GroupsRegistry::group(&key, cx)` → panel subscribes to `GroupEvent` | | Timeline | `Group::messages()` (already `chat::Message`s) | +| Load older messages | `GroupsRegistry::load_more(&key, cx)`; spinner from `Group::loading_more()` | | Send / react | `Group::send_message` / `send_reaction` | | Members, admins, roles, flags | `Group::members/admins/roles/metadata` | | Membership badge (member, pending, refused, removed) | `Group::membership()` | -- 2.54.0 From cd7b4597510f99aaa0fca9d2f09c6d4b44cd6c4a Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 30 Sep 2026 12:52:26 +0700 Subject: [PATCH 07/27] add GroupParticipants state event support --- crates/nip29/src/group.rs | 15 ++++++++++++-- crates/nip29/src/protocol.rs | 34 ++++++++++++++++++++++++++++++- docs/nip29-backend-plan.md | 39 ++++++++++++++++++++++++++++++++---- 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/crates/nip29/src/group.rs b/crates/nip29/src/group.rs index c15a8f40..f1adc9af 100644 --- a/crates/nip29/src/group.rs +++ b/crates/nip29/src/group.rs @@ -10,8 +10,9 @@ use state::NostrRegistry; use crate::LOCAL_KEYS; use crate::protocol::{ - GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata, GroupPins, GroupRoles, - MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS, TimelineWindow, + GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata, GroupParticipants, GroupPins, + GroupRoles, MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS, + TimelineWindow, }; const REPLAY_LIMIT: usize = 100; @@ -40,6 +41,7 @@ pub struct Group { members: GroupMembers, admins: GroupAdmins, roles: GroupRoles, + participants: GroupParticipants, pins: GroupPins, membership: Membership, membership_events: Vec, @@ -64,6 +66,7 @@ impl Group { members: GroupMembers::default(), admins: GroupAdmins::default(), roles: GroupRoles::default(), + participants: GroupParticipants::default(), pins: GroupPins::default(), membership: Membership::Unknown, membership_events: Vec::new(), @@ -99,6 +102,10 @@ impl Group { &self.roles } + pub fn participants(&self) -> &GroupParticipants { + &self.participants + } + pub fn pins(&self) -> &GroupPins { &self.pins } @@ -478,6 +485,10 @@ impl Group { Ok(roles) => self.roles = roles, Err(_) => return false, }, + Kind::GroupLivekitParticipants => match GroupParticipants::parse(event) { + Ok(participants) => self.participants = participants, + Err(_) => return false, + }, Kind::Custom(PIN_LIST) => match GroupPins::parse(event) { Ok(pins) => self.pins = pins, Err(_) => return false, diff --git a/crates/nip29/src/protocol.rs b/crates/nip29/src/protocol.rs index fbf82431..c6c3c41a 100644 --- a/crates/nip29/src/protocol.rs +++ b/crates/nip29/src/protocol.rs @@ -22,11 +22,12 @@ pub const RENDER_KINDS: [Kind; 5] = [ pub const PIN_LIST: u16 = 39_005; pub const UPDATE_PIN_LIST: u16 = 9_010; -pub const STATE_KINDS: [Kind; 5] = [ +pub const STATE_KINDS: [Kind; 6] = [ Kind::GroupMetadata, Kind::GroupAdmins, Kind::GroupMembers, Kind::GroupRoles, + Kind::GroupLivekitParticipants, Kind::Custom(PIN_LIST), ]; @@ -519,6 +520,37 @@ impl GroupRoles { } } +#[derive(Debug, Clone, Default)] +pub struct GroupParticipants(Vec); + +impl GroupParticipants { + pub fn parse(event: &Event) -> Result { + if event.kind != Kind::GroupLivekitParticipants { + bail!("not a group participants event: kind {}", event.kind); + } + + let mut participants = Vec::new(); + + for tag in event.tags.iter().filter(|tag| tag.kind() == "participant") { + let Some(public_key) = tag.content() else { + continue; + }; + + participants.push(PublicKey::parse(public_key)?); + } + + Ok(Self(participants)) + } + + pub fn contains(&self, public_key: &PublicKey) -> bool { + self.0.contains(public_key) + } + + pub fn iter(&self) -> impl Iterator { + self.0.iter() + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum Pin { Event(EventId), diff --git a/docs/nip29-backend-plan.md b/docs/nip29-backend-plan.md index e592e28a..7275e067 100644 --- a/docs/nip29-backend-plan.md +++ b/docs/nip29-backend-plan.md @@ -19,7 +19,7 @@ models as two entities. | Group id | random string, relay-chosen | `h` tag on everything a user sends; `d` tag on relay state events | | Timeline | any event kind + `h` | v1 renders kinds `1`, `9`, `11`, `1111` and `7`; every other kind is cached for the `previous` window but not rendered, except the membership kinds below | | Timeline refs | `previous` tag, first 8 hex chars of event ids | built from the last 50 events seen on that relay (any kind — NIP-29 timeline references), excluding our own; send up to 3 | -| Relay state | `39000` metadata, `39001` admins, `39002` members, `39003` roles | relay-signed, addressable by `d`; read, never written by the client | +| Relay state | `39000` metadata, `39001` admins, `39002` members, `39003` roles, `39004` LiveKit participants | relay-signed, addressable by `d`; read, never written by the client | | Membership | `9021` join, `9022` leave (user-signed); relay answers with `9000` put-user / `9001` remove-user | membership = latest `9000`/`9001` naming our pubkey; no event means not a member, and `Pending`/`Refused` are tracked locally for private groups | | Own group list | `10009` (NIP-51 simple groups) | `["group", id, relay, name?]` plus `["r", relay]`; replaceable, published to NIP-65 relays | | Reference | `naddr1…` of kind `39000` with the relay self pubkey and a relay hint, optionally `?invite=` | the self pubkey is the author of the fetched `39000`; no `39000` means no canonical naddr | @@ -235,6 +235,16 @@ impl GroupRoles { pub fn roles(&self) -> &[Role]; } +/// kind:39004 — who is live in the group's AV room; `participant` tags, not `p`. +/// Absence means nothing is known, which is not the same as an empty room. +pub struct GroupParticipants(Vec); + +impl GroupParticipants { + pub fn parse(event: &Event) -> Result; + pub fn contains(&self, public_key: &PublicKey) -> bool; + pub fn iter(&self) -> impl Iterator; +} + /// kind:10009 — the account's own list. pub struct GroupList(Vec); pub struct GroupListEntry { pub id: GroupId, pub relay: RelayUrl, pub name: Option } @@ -469,9 +479,29 @@ group's relay only, and treat the relay's `OK` as the truth. whose next page shares the `until` second cannot advance the cursor, so it can re-fetch the same page. Neither loops on its own; both need a "load older messages" button bound to `Group::loading_more()` to be observable at all. -5. LiveKit AV (`livekit` tag, `39004`, the `/.well-known/nip29/livekit` token - endpoint with a NIP-98 `27235` authorization event) and fork/migration - detection through the admins' `10009`. +5. LiveKit AV and fork/migration detection, split in three: + 1. **`39004` participants. Done.** `GroupParticipants` joins `protocol.rs` + with the same shape as `GroupMembers` but reads `participant` tags (the NIP + does not use `p` here); one malformed pubkey fails the whole event, as in + `GroupAdmins`. The kind joins `STATE_KINDS`, so the standing subscription + already carries it, and `absorb_state` folds it into `Group::participants()`. + Nothing is written by us — the relay publishes it and updates it as people + come and go, so it rides the live tail like any other state event. + "Absent" and "empty" are deliberately the same value here: unlike + `supported_kinds`, the NIP gives the empty list no special meaning, so the + UI reads "no one is live" either way. + 2. The `/.well-known/nip29/livekit/` token endpoint: a NIP-98 + `27235` event in `Authorization`, returning the LiveKit JWT and server URL, + plus the `204` probe at `/.well-known/nip29/livekit` that tells the UI + whether a relay supports AV at all. Not started. This is the only part that + needs an HTTP client in the crate, so it is where the dependency decision + (if any) lands; `livekit` on `39000` is already parsed but currently inert. + 3. Fork/migration detection: read the `10009` of the group's admins (pubkeys + from `39001`, cached locally) and compare the relay hint against the relay + we are talking to; a different hint means the group may have moved or been + forked, which is the client's cue to offer fetching from the new relay. + Not started; the periodic — and MUST-when-offline — trigger is what + `Monitor` would feed, so the two decisions go together. ## UI later — the hooks this backend leaves @@ -483,6 +513,7 @@ group's relay only, and treat the relay's `OK` as the truth. | Load older messages | `GroupsRegistry::load_more(&key, cx)`; spinner from `Group::loading_more()` | | Send / react | `Group::send_message` / `send_reaction` | | Members, admins, roles, flags | `Group::members/admins/roles/metadata` | +| Who is in the AV room | `Group::participants()` | | Membership badge (member, pending, refused, removed) | `Group::membership()` | | Copy an invite link | `GroupMetadata::naddr(relay, invite)` | | Leave | `GroupsRegistry::leave` | -- 2.54.0 From cfec6dfc85b9d7f0de673975c9a617370fa857f1 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 30 Sep 2026 13:08:05 +0700 Subject: [PATCH 08/27] add NIP-29 LiveKit token and fork detection --- Cargo.lock | 3 + Cargo.toml | 2 +- crates/nip29/Cargo.toml | 3 + crates/nip29/src/group.rs | 85 ++++++++++++++++++++++- crates/nip29/src/lib.rs | 126 +++++++++++++++++++++++++++++++++++ crates/nip29/src/protocol.rs | 70 +++++++++++++++++++ docs/nip29-backend-plan.md | 99 ++++++++++++++++++++++----- 7 files changed, 369 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f1e2cb2c..0f1b9e38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4602,9 +4602,12 @@ dependencies = [ "anyhow", "chat", "flume 0.11.1", + "futures", "gpui-pre", "log", + "nostr", "nostr-sdk", + "serde_json", "smallvec", "state", ] diff --git a/Cargo.toml b/Cargo.toml index 50839b5b..8ca3bc30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ nostr-blossom = { git = "https://github.com/rust-nostr/nostr" } nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" } nostr-connect = { git = "https://github.com/rust-nostr/nostr" } nostr-sdk = { git = "https://github.com/rust-nostr/nostr" } -nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] } +nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip98" ] } # Crypto (NIP-17 encrypted file messages) aes-gcm = "0.10" diff --git a/crates/nip29/Cargo.toml b/crates/nip29/Cargo.toml index b69b7874..05fbb7c1 100644 --- a/crates/nip29/Cargo.toml +++ b/crates/nip29/Cargo.toml @@ -5,8 +5,11 @@ edition.workspace = true publish.workspace = true [dependencies] +nostr.workspace = true nostr-sdk.workspace = true anyhow.workspace = true +futures.workspace = true +serde_json.workspace = true state = { path = "../state" } chat = { path = "../chat" } diff --git a/crates/nip29/src/group.rs b/crates/nip29/src/group.rs index f1adc9af..2a41e138 100644 --- a/crates/nip29/src/group.rs +++ b/crates/nip29/src/group.rs @@ -3,7 +3,10 @@ use std::time::Duration; use anyhow::{Result, anyhow, bail}; use chat::Message; +use futures::io::AsyncReadExt; +use gpui::http_client::{AsyncBody, Builder, HttpRequestExt, Method, RedirectPolicy, StatusCode}; use gpui::{App, AppContext, Context, EventEmitter, Task}; +use nostr::nips::nip98::{HttpData, HttpMethod}; use nostr_sdk::prelude::*; use smallvec::{SmallVec, smallvec}; use state::NostrRegistry; @@ -11,13 +14,14 @@ use state::NostrRegistry; use crate::LOCAL_KEYS; use crate::protocol::{ GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata, GroupParticipants, GroupPins, - GroupRoles, MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS, - TimelineWindow, + GroupRoles, LivekitSession, MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS, + STATE_KINDS, TimelineWindow, }; const REPLAY_LIMIT: usize = 100; const PAGE_LIMIT: usize = 50; const PAGE_TIMEOUT: Duration = Duration::from_secs(10); +const LIVEKIT_TIMEOUT: Duration = Duration::from_secs(10); #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Absorbed { @@ -45,6 +49,7 @@ pub struct Group { pins: GroupPins, membership: Membership, membership_events: Vec, + elsewhere: Option, state_ids: HashMap, messages: Vec, index: HashSet, @@ -70,6 +75,7 @@ impl Group { pins: GroupPins::default(), membership: Membership::Unknown, membership_events: Vec::new(), + elsewhere: None, state_ids: HashMap::new(), messages: Vec::new(), index: HashSet::new(), @@ -114,6 +120,10 @@ impl Group { &self.membership } + pub fn elsewhere(&self) -> Option<&GroupKey> { + self.elsewhere.as_ref() + } + pub fn messages(&self) -> &[Message] { &self.messages } @@ -258,6 +268,15 @@ impl Group { cx.notify(); } + pub(crate) fn set_elsewhere(&mut self, elsewhere: Option, cx: &mut Context) { + if self.elsewhere == elsewhere { + return; + } + self.elsewhere = elsewhere; + cx.emit(GroupEvent::Updated); + cx.notify(); + } + pub(crate) fn connect(&mut self, cx: &mut Context) { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); @@ -330,6 +349,68 @@ impl Group { cx.notify(); } + pub fn livekit_session(&self, cx: &App) -> Task> { + let url = match self.key.livekit_token_url() { + Ok(url) => url, + Err(error) => return Task::ready(Err(error)), + }; + + let nostr = NostrRegistry::global(cx); + let signer = nostr.read(cx).signer(); + + let client = cx.http_client(); + + cx.background_spawn(async move { + let authorization = HttpData::new(url.clone(), HttpMethod::GET) + .to_authorization(&signer) + .await?; + + let request = Builder::new() + .uri(url.as_str()) + .method(Method::GET) + .header("Authorization", authorization) + .follow_redirects(RedirectPolicy::NoFollow) + .timeout(LIVEKIT_TIMEOUT) + .body(AsyncBody::default())?; + + let response = client.send(request).await?; + + if !response.status().is_success() { + bail!( + "nip29: the AV token endpoint answered {}", + response.status() + ); + } + + let mut body = Vec::new(); + response.into_body().read_to_end(&mut body).await?; + + LivekitSession::parse(&body) + }) + } + + pub fn livekit_supported(&self, cx: &App) -> Task> { + let url = match self.key.livekit_probe_url() { + Ok(url) => url, + Err(error) => return Task::ready(Err(error)), + }; + + let client = cx.http_client(); + + cx.background_spawn(async move { + let request = Builder::new() + .uri(url.as_str()) + .method(Method::GET) + .follow_redirects(RedirectPolicy::NoFollow) + .timeout(LIVEKIT_TIMEOUT) + .body(AsyncBody::default())?; + + let response = client.send(request).await?; + + Ok(response.status() == StatusCode::NO_CONTENT) + }) + } + pub(crate) fn save_envelope(&self, event: Event, cx: &App) -> Task> { let cache_tag = self.key.cache_tag(); let nostr = NostrRegistry::global(cx); diff --git a/crates/nip29/src/lib.rs b/crates/nip29/src/lib.rs index 17ab70ba..be42bdeb 100644 --- a/crates/nip29/src/lib.rs +++ b/crates/nip29/src/lib.rs @@ -19,11 +19,13 @@ pub use protocol::*; pub(crate) static LOCAL_KEYS: LazyLock = LazyLock::new(Keys::generate); const LIST_SUBSCRIPTION: &str = "nip29-list"; +const FORK_SUBSCRIPTION: &str = "nip29-forks"; const GROUP_PREFIX: &str = "grp-"; const BROWSE_PREFIX: &str = "brw-"; const PAGE_PREFIX: &str = "pg-"; const BROWSE_LIMIT: usize = 500; const BROWSE_TIMEOUT: Duration = Duration::from_secs(10); +const FORK_INTERVAL: Duration = Duration::from_secs(15 * 60); pub fn init(cx: &mut App) { GroupsRegistry::set_global(cx.new(GroupsRegistry::new), cx); @@ -51,12 +53,17 @@ enum Signal { Event(SubscriptionId, Event), Eose(SubscriptionId), Closed(SubscriptionId, String), + Forks, } fn list_subscription_id() -> SubscriptionId { SubscriptionId::new(LIST_SUBSCRIPTION) } +fn fork_subscription_id() -> SubscriptionId { + SubscriptionId::new(FORK_SUBSCRIPTION) +} + fn subscription_id(key: &GroupKey) -> SubscriptionId { SubscriptionId::new(format!("{GROUP_PREFIX}{:08x}", key.uniq_id() as u32)) } @@ -79,6 +86,7 @@ fn is_page(id: &SubscriptionId) -> bool { fn is_own_subscription(id: &SubscriptionId) -> bool { let id = id.as_str(); id == LIST_SUBSCRIPTION + || id == FORK_SUBSCRIPTION || id.starts_with(GROUP_PREFIX) || id.starts_with(BROWSE_PREFIX) || id.starts_with(PAGE_PREFIX) @@ -92,6 +100,8 @@ pub struct GroupsRegistry { browse: HashMap, routes: HashMap, observers: HashMap, + forks: HashMap, + fork_authors: HashSet, signal_tx: flume::Sender, signal_rx: flume::Receiver, notification_listener: Option>, @@ -142,6 +152,8 @@ impl GroupsRegistry { browse: HashMap::new(), routes: HashMap::new(), observers: HashMap::new(), + forks: HashMap::new(), + fork_authors: HashSet::new(), signal_tx: tx, signal_rx: rx, notification_listener: None, @@ -317,6 +329,8 @@ impl GroupsRegistry { self.groups.clear(); self.observers.clear(); + self.forks.clear(); + self.fork_authors.clear(); self.list = GroupList::default(); let nostr = NostrRegistry::global(cx); @@ -324,6 +338,7 @@ impl GroupsRegistry { let mut ids: Vec = routes.into_keys().collect(); ids.push(list_subscription_id()); + ids.push(fork_subscription_id()); ids.extend(self.browse.keys().cloned()); self.browse.clear(); @@ -368,6 +383,8 @@ impl GroupsRegistry { } Err(error) => log::warn!("nip29: loading the group list failed: {error}"), })); + + self.watch_forks(cx); } fn merge(&mut self, list: GroupList, cx: &mut Context) { @@ -382,6 +399,101 @@ impl GroupsRegistry { cx.notify(); } + fn watch_forks(&mut self, cx: &mut Context) { + let executor = cx.background_executor().clone(); + let tx = self.signal_tx.clone(); + + self.tasks.push(cx.background_spawn(async move { + loop { + executor.timer(FORK_INTERVAL).await; + + if tx.send_async(Signal::Forks).await.is_err() { + break; + } + } + })); + } + + fn admin_keys(&self, cx: &App) -> HashSet { + let mut keys = HashSet::new(); + + for group in &self.groups { + for (admin, _) in group.read(cx).admins().entries() { + keys.insert(*admin); + } + } + + keys + } + + fn sync_forks(&mut self, force: bool, cx: &mut Context) { + let authors = self.admin_keys(cx); + + if authors.is_empty() || (!force && authors == self.fork_authors) { + return; + } + + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let filter = Filter::new() + .authors(authors.iter().copied()) + .kind(Kind::SimpleGroups); + + self.fork_authors = authors; + + self.tasks.push(cx.background_spawn(async move { + if let Err(error) = client + .subscribe(ReqTarget::auto([filter])) + .with_id(fork_subscription_id()) + .await + { + log::warn!("nip29: subscribing to the admins' group lists failed: {error}"); + } + })); + } + + fn absorb_fork_list(&mut self, event: &Event, cx: &mut Context) { + let Ok(list) = GroupList::parse(event) else { + log::warn!("nip29: ignoring a malformed admin group list"); + return; + }; + + let author = event.pubkey; + + if let Some((seen, _)) = self.forks.get(&author) + && *seen >= event.created_at + { + return; + } + + self.forks.insert(author, (event.created_at, list)); + self.evaluate_forks(cx); + } + + fn evaluate_forks(&mut self, cx: &mut Context) { + let groups = self.groups.clone(); + + for group in groups { + let elsewhere = { + let group = group.read(cx); + let key = group.key(); + + group + .admins() + .entries() + .iter() + .filter_map(|(admin, _)| self.forks.get(admin)) + .flat_map(|(_, list)| list.entries()) + .find(|entry| entry.id == *key.id() && entry.relay != *key.relay()) + .map(|entry| GroupKey::new(entry.relay.clone(), entry.id.clone())) + }; + + group.update(cx, |group, cx| { + group.set_elsewhere(elsewhere, cx); + }); + } + } + fn ensure(&mut self, key: GroupKey, cx: &mut Context) { if self.group(&key, cx).is_some() { return; @@ -399,6 +511,7 @@ impl GroupsRegistry { } GroupEvent::Updated => { this.refresh_entry(group, cx); + this.sync_forks(false, cx); } _ => {} }), @@ -572,6 +685,11 @@ impl GroupsRegistry { return; } + if id == fork_subscription_id() { + self.absorb_fork_list(&event, cx); + return; + } + if self.browse.contains_key(&id) { self.absorb_candidate(&id, &event, cx); return; @@ -660,6 +778,11 @@ impl GroupsRegistry { return; } + if id == fork_subscription_id() { + log::warn!("nip29: the admins' group lists were refused: {reason}"); + return; + } + let Some(key) = self.routes.get(&id).cloned() else { return; }; @@ -761,6 +884,9 @@ impl GroupsRegistry { Signal::Closed(id, reason) => { this.handle_closed(id, reason, cx); } + Signal::Forks => { + this.sync_forks(true, cx); + } }) .ok(); } diff --git a/crates/nip29/src/protocol.rs b/crates/nip29/src/protocol.rs index c6c3c41a..a1f8bf2a 100644 --- a/crates/nip29/src/protocol.rs +++ b/crates/nip29/src/protocol.rs @@ -251,6 +251,47 @@ impl GroupKey { self.hash(&mut hasher); hasher.finish() } + + pub fn livekit_token_url(&self) -> Result { + self.livekit_url(Some(self.id.as_str())) + } + + pub fn livekit_probe_url(&self) -> Result { + self.livekit_url(None) + } + + fn livekit_url(&self, group: Option<&str>) -> Result { + let mut url: Url = self.relay.clone().into(); + let scheme = if self.relay.scheme().is_secure() { + "https" + } else { + "http" + }; + + url.set_path("/"); + url.set_query(None); + url.set_fragment(None); + url.set_scheme(scheme) + .map_err(|_| anyhow!("{scheme} is not available for {}", self.relay))?; + + { + let mut segments = url + .path_segments_mut() + .map_err(|_| anyhow!("{} cannot carry a path", self.relay))?; + + segments + .pop_if_empty() + .push(".well-known") + .push("nip29") + .push("livekit"); + + if let Some(group) = group { + segments.push(group); + } + } + + Ok(url) + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -605,6 +646,35 @@ impl GroupPins { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LivekitSession { + pub token: String, + pub server_url: String, +} + +impl LivekitSession { + pub fn parse(body: &[u8]) -> Result { + let value: serde_json::Value = serde_json::from_slice(body)?; + + let token = value + .get("token") + .or_else(|| value.get("jwt")) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| anyhow!("the AV response carries no token"))?; + + let server_url = value + .get("url") + .or_else(|| value.get("server_url")) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| anyhow!("the AV response carries no server url"))?; + + Ok(Self { + token: token.to_owned(), + server_url: server_url.to_owned(), + }) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct GroupListEntry { pub id: GroupId, diff --git a/docs/nip29-backend-plan.md b/docs/nip29-backend-plan.md index 7275e067..77107bcc 100644 --- a/docs/nip29-backend-plan.md +++ b/docs/nip29-backend-plan.md @@ -490,18 +490,61 @@ group's relay only, and treat the relay's `OK` as the truth. "Absent" and "empty" are deliberately the same value here: unlike `supported_kinds`, the NIP gives the empty list no special meaning, so the UI reads "no one is live" either way. - 2. The `/.well-known/nip29/livekit/` token endpoint: a NIP-98 - `27235` event in `Authorization`, returning the LiveKit JWT and server URL, - plus the `204` probe at `/.well-known/nip29/livekit` that tells the UI - whether a relay supports AV at all. Not started. This is the only part that - needs an HTTP client in the crate, so it is where the dependency decision - (if any) lands; `livekit` on `39000` is already parsed but currently inert. - 3. Fork/migration detection: read the `10009` of the group's admins (pubkeys - from `39001`, cached locally) and compare the relay hint against the relay - we are talking to; a different hint means the group may have moved or been - forked, which is the client's cue to offer fetching from the new relay. - Not started; the periodic — and MUST-when-offline — trigger is what - `Monitor` would feed, so the two decisions go together. + 2. The `/.well-known/nip29/livekit/` token endpoint and the `204` + probe at `/.well-known/nip29/livekit`. **Done.** The HTTP client is the one + GPUI already owns: `cx.http_client()` returning `Arc`, + already wired to `reqwest_client::ReqwestClient` on desktop and to `fetch` + on wasm — the same path `state::nip05` uses, which is why no HTTP + dependency lands in this crate. Its `get()` convenience cannot set headers, + so the request goes through `HttpClient::send` with a `Builder`, an + explicit `RedirectPolicy::NoFollow` (the `Authorization` header is signed + for exactly one URL, so a redirect could only ever fail verification) and a + 10s `timeout` — both are opt-in extensions, and a request without them has + none. The header itself is NIP-98 from the SDK: `nostr/nip98` is now + enabled on the workspace `nostr` entry and nip29 declares `nostr` itself, + so `HttpData::new(url, HttpMethod::GET).to_authorization(&signer)` builds + `Nostr ` — the `u` + `method` tags that the crate's own + `verify_auth_header` demands, though the NIP's prose mentions only `u`. + `GroupKey::livekit_token_url` / `livekit_probe_url` derive the endpoint + purely: the relay URL is rewritten to http(s) (keeping host and port), + reset to the origin, and the path built with `pop_if_empty().push(..)` so + each segment is escaped — a relay with its own path prefix loses it, since + the NIP puts the endpoint at the absolute path. `Group::livekit_session` + and `Group::livekit_supported` are the two calls, both `&App` + + `background_spawn` like `publish`. The session is returned, not stored: + tokens are per-join artifacts, not group state, so the caller owns it. + Only `livekit_supported`'s unauthenticated GET ignores status beyond + `204`; the token endpoint's response shape is **not specified by the NIP** + (see Decisions) and no LiveKit client library is involved — the backend + stops at the JWT and server URL. + 3. Fork/migration detection. **Done.** One standing `nip29-forks` + subscription over `{authors: , kinds: [10009]}` + on `ReqTarget::auto` — the admins' own group lists live on their outbox + relays, so this works with the group's relay down, and the admin pubkeys + come from `39001`, which is hydrated from the envelope cache, so it works + after a restart too. Each event is folded into `forks` (author → newest + `GroupList`, guarded on `created_at` because `10009` is replaceable), then + `evaluate_forks` recomputes every group. `Group::elsewhere()` is `Some` + when an admin of that group lists the same group id at a different relay; + the data cannot distinguish a move from a fork, hence the neutral name. + Evaluation is incremental, on each event, rather than batched on EOSE: + `ReqTarget::auto` fans out to several relays, so the first EOSE would + otherwise evaluate — and clear — against one relay's partial view. + Nothing is auto-joined, since the NIP asks the client to *offer*; the + offer is `GroupsRegistry::join_key(elsewhere, None, cx)`, which is exactly + "fetch from the new relay and update our own `10009`". Two triggers, + because the NIP's "periodically" and its "MUST when the relay is offline" + are different things. Reactively, the group observer calls + `sync_forks(false)` on `GroupEvent::Updated`, subscribing as soon as the + first `39001` lands and re-subscribing only when the admin set actually + changes — the guard matters, since `Updated` also fires for metadata, + pins, membership and a finished page. Periodically, a + `background_executor().timer` loop started in `load` pushes + `Signal::Forks` every 15 minutes and `sync_forks(true)` re-queries + regardless: that is the recovery path for a subscription that died or a + client that was offline, not the detection path. `reset` drops the fork + state and unsubscribes the REQ; a `CLOSED` on it is only logged, because + the tick retries and there is nothing to repair. ## UI later — the hooks this backend leaves @@ -514,6 +557,9 @@ group's relay only, and treat the relay's `OK` as the truth. | Send / react | `Group::send_message` / `send_reaction` | | Members, admins, roles, flags | `Group::members/admins/roles/metadata` | | Who is in the AV room | `Group::participants()` | +| Join AV (get a LiveKit session) | `Group::livekit_session(cx)` → `await` → `LivekitSession { token, server_url }` | +| Is AV available here | `Group::livekit_supported(cx)` after seeing `metadata().livekit` | +| The group may have moved or been forked | `Group::elsewhere()` → offer `GroupsRegistry::join_key(elsewhere, None, cx)` | | Membership badge (member, pending, refused, removed) | `Group::membership()` | | Copy an invite link | `GroupMetadata::naddr(relay, invite)` | | Leave | `GroupsRegistry::leave` | @@ -533,11 +579,14 @@ join dialog accepts a pasted naddr (with or without `?invite=`). System notifications can hang off `GroupEvent::Incoming` when the window is inactive. Nothing new is needed from GPUI to support this backend. The local checkouts -are `gpui-pre` 0.3.5 (aliased as `gpui`) and `gpui-base` 0.7.0, which `crates/ui` -styles; the only GPUI APIs the backend uses are the ones `chat` and `community` +are `gpui-pre` 0.3.7 (aliased as `gpui`) and `gpui-base` 0.7.0, which `crates/ui` +styles; the GPUI APIs the backend uses are the ones `chat` and `community` already use — `Entity`/`WeakEntity`, `Context::spawn`, `background_spawn`, -`EventEmitter`, `Subscription` — and the panel's components (`ListState`, -`MessageRow`, `Avatar`, `Button`, `Scrollbar`) already exist in the app. +`EventEmitter`, `Subscription` — plus two that `state` already uses: +`App::http_client()` for the AV token fetch and +`App::background_executor().timer(..)` for the fork tick. The panel's components +(`ListState`, `MessageRow`, `Avatar`, `Button`, `Scrollbar`) already exist in the +app. ## Tests @@ -581,3 +630,21 @@ already use — `Entity`/`WeakEntity`, `Context::spawn`, `background_spawn`, handles the reads. 8. **References.** `naddr` only (`?invite=` accepted); `id@relay` is not. A group whose relay publishes no `39000` cannot be referenced or joined in v1. +9. **LiveKit response shape.** The NIP says the token endpoint returns "the + LiveKit JWT and the URL of the LiveKit server" but names no fields, so + `LivekitSession::parse` reads `token` (or `jwt`) and `url` (or `server_url`) + and fails loudly on anything else. This is a guess, not a spec, and it is the + one part of the AV path that a real relay can contradict; it is worth + re-checking against whichever relay is targeted before the UI lands. + Likewise `livekit_supported` treats "not `204`" as "no AV" rather than an + error, because it only feeds a button. +10. **`Monitor`.** Not adopted. It is only reachable through + `ClientBuilder::monitor(Monitor::new(n))`, so nothing can read per-relay + status until `crates/state` opts in, and its `StatusChanged` stream does not + distinguish a permanent outage from a reconnect — so it would need filtering + before it could mean anything. The only thing it buys here is running the + admins' `10009` check the instant a group's relay drops, rather than at the + next 15-minute tick. The NIP frames that as MUST, but the substance is + already covered: the admins come from the cached `39001`, so the check runs + with the relay gone, just later. If that latency ever matters, the hook is + `sync_forks(true, cx)` driven by a `StatusChanged` consumer — one place. -- 2.54.0 From 4678e5879fad8c0a529c394ad5339b9cceb70514 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 30 Sep 2026 13:35:33 +0700 Subject: [PATCH 09/27] clean up --- docs/concord-usage.md | 780 ------------------------------------- docs/nip29-backend-plan.md | 650 ------------------------------- 2 files changed, 1430 deletions(-) delete mode 100644 docs/concord-usage.md delete mode 100644 docs/nip29-backend-plan.md diff --git a/docs/concord-usage.md b/docs/concord-usage.md deleted file mode 100644 index 74c23a08..00000000 --- a/docs/concord-usage.md +++ /dev/null @@ -1,780 +0,0 @@ -# Using the Concord backend - -`crates/concord` is a protocol crate: derivations, envelopes, folds and the local -state document. It has no GPUI dependency and owns no strings a user reads — the -UI layer decides every rendering. This document is the map from a UI action to -the calls it makes. - -A community is addressed by a `community_id` (never on the wire) plus three -secrets: `community_root` (read access — holding it *is* membership), -`control_root` (write access to the Control Plane, held by staff), and per-Channel -keys for private channels. Authority is a roster of owner-rooted signed grants, -folded independently by every client. - -## Modules - -Files follow the CORD documents. The frozen derivations of Appendix A and the id -vocabulary are shared substrate — every document calls them — so they live outside -`cords` in `utils::derive` (re-exported as `derive`) and `types`. - -| Module | Owns | -| --- | --- | -| `cord01` | Private Streams: the seal/wrap envelope and the NIP-44 helpers | -| `cord02` | Communities: identity, epochs, metadata, the Control Plane fold and writer | -| `cord02::guestbook` | Joins, leaves, kicks, snapshots, the member list | -| `cord02::list` | The Community List (a member's own memberships, across devices) | -| `cord03` | Channels: Channel metadata and the Chat Plane | -| `cord04` | Roles: chained editions, parse/hash/fold, the roster, permissions, the banlist | -| `cord04::pins` | Pin Lists, and the key disclosure a keyless reader verifies | -| `cord05` | Invite bundles, links, the Direct Invite, the Invite List | -| `cord06` | Key rotations, refounding, compaction, dissolution | -| `derive` | Every frozen HKDF derivation and coordinate | -| `state` | The community state document and its pure folds — no I/O | - -`CommunityId`, `ChannelId`, `RoleId`, `Epoch` and `Extra` (crate-internal) come from -the private `types` module and are re-exported at the crate root. - -Nothing in `concord` touches a relay or a database. The local rumor cache, the -state documents and the relay paging walk live one crate up, in -`community::cache` and `community::history`, and are what a client actually calls -(read on below). - -CORD-07 (audio/video) is unimplemented. CORD-08's timer has no file of its own: it -lives in the metadata it reads (`cord02`) and the fold it filters (`cord03`). - -Read `CommunityId` as "this community", `ChannelId` as "this channel", `Epoch` as -"which key generation". Nothing else in the API needs internal state. - -## Creating a community - -```rust -use concord::cord02::{self, CommunityMetadata}; -use concord::state::CommunityState; -use community::cache::save_state; - -let metadata = CommunityMetadata { name: "Room".into(), ..Default::default() }; -let minted = cord02::genesis(&owner_keys, &metadata, now_secs).await?; - -// minted.identity — community_id, owner, owner_salt (verify() recomputes it) -// minted.wraps — the two owner-signed genesis editions, already sealed -// minted.channel_id — the #general channel -for wrap in &minted.wraps { - client.send_event(wrap).to(&relays).await?; -} -``` - -The owner then needs the folded state, which is also what every member does on -join: - -```rust -use concord::derive::{control_group_key, control_signer_group_key}; -use concord::cord04::ParsedEdition; - -let read = control_group_key(&minted.community_root, &minted.identity.community_id, Epoch(0))?; -let signer = control_signer_group_key(&minted.control_root, &minted.identity.community_id, Epoch(0))?; -let editions: Vec = minted - .wraps - .iter() - .map(|wrap| cord02::open_edition(wrap, &read, &signer.pk(), true)) - .collect::>()?; - -let mut state = CommunityState::from_genesis(&minted, &editions, added_at_ms)?; -save_state(&client, &state).await?; -``` - -Put the community's relay list into `state.relays` and add those relays to the -client explicitly — coop's client is a gossip client with no background refresh. - -Creating is not finished until the membership is announced. The two writes are -independent and both best-effort: the genesis wraps go to the community's -relays, and the membership goes to the account's own Community List (below), so a -new device — or another client — can find the community without an invite. -`crates/community`'s `sync::create` performs both. - -## Joining - -An invite link resolves to a bundle: - -```rust -use concord::cord05::{self, BundleState, invite_bundle_key}; - -let link = cord05::parse_link(url)?; // link_signer, token, bootstrap_relays, naddr - -// The crate does no I/O: fetch the naddr from the fragment's relays, then: -let invite = match cord05::parse_bundle_event(&event, &link.link_signer, &invite_bundle_key(&link.token))? { - BundleState::Live(invite) => invite, // validate() already ran - BundleState::Revoked => return Ok(None), // a tombstone at the coordinate -}; -``` - -A Direct Invite arrives as a NIP-59 gift wrap addressed to the member: - -```rust -let (inviter, invite) = cord05::unwrap_direct_invite(&wrap, &my_keys).await?; -``` - -Either way the invite carries `community_id`, `owner`, `owner_salt`, -`community_root`, `root_epoch`, `control_pk`, the granted `channels` -(`ChannelGrant { id, key, epoch, name }`) and the relay set. `invite.expired(now_ms)` -is a preview rule: a past expiry still renders, but the join is refused. - -Then publish a join so the member list sees the member before any backfill: - -```rust -use concord::derive::guestbook_group_key; -use concord::cord02::guestbook; - -let guestbook = guestbook_group_key(&invite.community_root, &invite.community_id, invite.root_epoch)?; -let rumor = cord02::guestbook::build_join(my_pk, Some((creator_npub, label)), now_ms); -let (wrap, _) = cord02::guestbook::seal_rumor(&rumor, &guestbook, &my_keys).await?; -client.send_event(&wrap).to(&relays).await?; -``` - -## Reading the Control Plane - -```rust -use concord::cord02::{self, ControlFold}; - -let editions: Vec = wraps - .iter() - .filter_map(|wrap| cord02::open_edition(wrap, &read, &control_pk, true).ok()) - .collect(); - -let control: ControlFold = - cord02::fold_control(&owner, &community_id, &editions, &state.floors(), &state.banned); -state.apply_fold(&control); -``` - -`ControlFold` is everything the community UI needs: - -| Field | Use | -| --- | --- | -| `community` | name, description, icon, banner, `message_expiration` | -| `channels` | the channel list; `deleted: true` means drop it | -| `roles` | `role()`, `roles_of()`, `effective_permissions()`, `is_authorized()`, `is_staff()` | -| `banned` | the banlist | -| `registries` / `is_public()` | each invite creator's live link signers | -| `pins` / `pin_content(id, channel)` | Pin List content per channel | -| `floors` | the committed heads the next fold is judged against | -| `gapped` | a chain hole: refetch the Control Plane before trusting what is missing | - -`None` on `community` or a channel means "this client saw no authorized edition", -never "the value is gone" — keep what the state already holds rather than walking -the community backwards. Feed `state.floors()` and `state.banned` into the next -fold; they are its memory. - -## Sending a message - -```rust -use concord::cord03::{self, build_message}; -use concord::derive::channel_group_key; - -let plane = channel_group_key(&community_root, &channel, epoch)?; // public channel -let rumor = build_message(my_pk, &channel, epoch, text, None, at_ms, timer); -let (wrap, wrap_key) = cord03::seal_rumor(&rumor, &plane, &my_keys, false).await?; -client.send_event(&wrap).to(&relays).await?; -``` - -- `epoch` is the **root** epoch for a public channel: its plane is - `channel_group_key(community_root, channel, root_epoch)`, so a Refounding moves - it along with the root. A private channel passes its own current channel epoch - (`state.channels` carries it) and derives from its own key instead of - `community_root`. -- `timer` is `control.community.message_expiration`; pass `None` when it is off. - The builder attaches the NIP-40 tag and `seal_rumor` mirrors it onto the wrap, - so relays drop the ciphertext too. -- `ephemeral: true` picks kind `21059` for typing indicators. Keep the returned - wrap key if the message may be deleted later — a kind-5 delete needs it. -- That `send_event` is the whole publish path; there is no optimistic echo. Feed - the wrap through the same ingest path the subscription uses so send-then-read - never waits on a relay round trip. - -`build_edit`, `build_reaction` and `build_delete` are the same shape, each a rumor -about an existing `EventId` rather than a mutation. - -## Reading a channel - -```rust -use concord::cord03::{self, fold, plane_keys}; -use community::cache; - -let planes = plane_keys(&held, &channel)?; // &[(Epoch, secret)] -let mut rumors = Vec::new(); - -for wrap in &wraps { - let Some((epoch, group)) = planes.iter().find(|(_, group)| group.pk() == wrap.pubkey) else { - continue; - }; - let Ok((opened, rumor)) = cord03::open(wrap, group, &channel, *epoch) else { - continue; - }; - cache::cache_rumor(&client, &channel, &opened).await?; - rumors.push(rumor); -} - -let messages = fold(&rumors, Timestamp::now(), |actor, citation, author| { - citation_ok(&owner, &community_id, actor, citation, &control.roles.floors) - && control.roles.can_act_on_member(actor, &owner, author, Permissions::MANAGE_MESSAGES) -}); -``` - -`ChatMessage` carries `content`, `at_ms`, `edited_at`, `deleted`, `reactions`, -`reply_to` and `thread_root` already resolved. The fold drops expired rumors; a -`deleted` row is still returned so the timeline keeps its shape. - -Relay history pages through the local cache: - -```rust -use community::history::{self, PageRegistry, Window}; - -// Every key the client still holds for the channel, newest epoch first: the -// state's current key (`ChannelKeyRef::key` at `epoch`) plus every `priors` -// entry a rotation stepped off. A public channel derives one per held root. -let held: Vec = state.held_keys(&channel); - -// The same registry `CommunityRegistry` shares with its notification pump: it is -// what carries each page's EOSE and CLOSED back to the round waiting on it. -let page = history::page( - client, - &pages, - &channel, - &held, - &relays, - Window::opening(cursor), - 20, - 50, -) -.await?; -let cached = cache::query_rumors(&client, &channel, None, 50, Some(&cord03::ROW_KINDS)).await?; -``` - -A key's `retired_at` (a `Timestamp`, set when a rotation supersedes it) is a read -cutoff: a wrap at that epoch with a later `created_at` is refused, so a retired -epoch is history and never a live plane an ejected holder can keep writing into. - -A relay is only ever *asked*: `history::page` installs **one REQ per page** over -`ReqTarget::manual` for the community's own relays (which is what makes the client -verify a wrap, deduplicate it and persist it), waits for every relay to settle, -and then reads the page back out of the local database — no wrap is ever consumed -straight off the wire, and `fetch_events` is not used anywhere. The wrap lands in -the client's shared event store through that ordinary ingest path; `cache_rumor` -then puts the decrypted rumor beside it, and only the rumor is ever served to a -reader. - -EOSE and CLOSED are **not** watched inside the page. The registry's notification -pump is the only consumer of `client.notifications()`; it delivers one -`PageReport` per settling relay into the `PageRegistry` under the page's -subscription id, and the page waits on that channel with `PAGE_TIMEOUT`. The id -is opaque (`concord-history-`) because a NIP-01 id is capped at 64 characters, -and the registry is what maps it back to the waiting round. - -A page ends on EOSE. A CLOSED ends it as unanswered, *except* NIP-42's -`auth-required`: the SDK re-issues that REQ under the same subscription id once -the handshake completes, so the page waits for the resubscribed answer rather -than writing off a relay that only wanted to authenticate. - -`history::page` walks newest-first across every held epoch, caches what it opens, and -reports what it saw: `oldest`/`newest` feed the caller's `ChannelCursor`, -`exhausted` is earned only by a short page *after* history was seen, and an -all-empty answer sets `failed` so a later round re-asks instead of sealing the -channel at "no more history". `unreadable` counts the wraps the page reached that -no held key could open — sealed past the cutoff their key's rotation set, or bound -to another channel — because those are history the reader is missing, not history -that is not there. Page down with `Window::older_than(seen.oldest)`, -open a channel with `Window::opening(cursor)` (wide cold, `newest - 60s` warm), -and read the region between two cursors with `Window::between(..)`. A `Window` -carries `Timestamp`s, so its bounds go straight into a NIP-01 filter with no unit -conversion; only a reader's millisecond `at_ms` narrows to a second at the query. -`query_rumors` is the read path when the group keys are gone; pass `kinds` to -budget rows apart from the events that only decorate them. `cache::wrapper_index` -reads the cached rows back keyed by the wrap they came from, which is what lets -`sync::fold` observe author and message times without re-opening a wrap it already -cached, and `cache::purge_expired(client, &channel, now)` runs at the top of every -round — the timer is cooperative, so the local store is the artifact that has to -forget. - -`sync::fold` counts the same thing over the whole store, per channel, in -`Snapshot.unreadable`. `sync_round` sums a round's pages into -`Progress.unreadable`, and `Community` keeps both per channel: `progress(channel)` -is the last completed round, `unreadable(channel)` is the count, and -`missing_key(channel)`, `channel_removed_at(channel)`, `removed_at()` and -`stranded()` are the rest of the honest read surface a panel needs to tell an -empty room from one it cannot read. `Community::due(channel)` is the other half — -whether a round is worth asking for yet (a round for `Older` and an explicit retry -never ask it), and `Community::tick`, driven by `CommunityRegistry` once per -`community::MIN_ROUND_INTERVAL`, re-folds and re-rounds the active channel of a -community whose last round is older than `STALE_AFTER`. - -`ChatAction::TimerNotice { seconds }` is a policy notice, not a message: render it -as an inline row only when its author passes -`control.roles.is_authorized(&author, &owner, Permissions::MANAGE_METADATA)`. - -## Membership - -```rust -let states = cord02::guestbook::coalesce(&rumors, now_ms, &refounders, |actor, target, citation| { - citation_ok(&owner, &community_id, actor, citation, &control.roles.floors) - && control.roles.can_act_on_member(actor, &owner, target, Permissions::KICK) -}); -let members = cord02::guestbook::complete_memberlist(&states, &observed, &granted, &control.banned, &BTreeMap::new()); -``` - -- `refounders` is the set of npubs whose rotations minted an epoch this client - verified (`CommunityState.refounders`) — a snapshot chunk is honored only from - one of them, and an empty set honors none. A rotation that delivered this - client no key still names its minter, so the set is filled by any base rotation - whose continuity verifies against a root held (`rekey::walk`); a List entry's - `refounder`, read by `list::JoinMaterial::refounder`, is the other source. -- `observed` is npub → ms for every author this client has seen publish anything - usable, which is what makes a member visible before their Join arrives. Only - count it forward. -- `granted` is every npub the roster ranks; they are members with no Guestbook - entry at all. -- `banned_at` is empty today, so a ban is terminal in the fold. Fill it when the - banlist head's timestamp is plumbed through. -- Removal is three separate actions, composed by the caller: strip the grant - (immediate and cheap), then the kick directive, then — for a ban — the rotation - that actually enforces it. - -## Moderation writes - -Every Control Plane write goes through one writer and one edition shape: - -```rust -let writer = ControlWriter { author: my_pk, read: read.clone(), signer: signer.clone() }; -let head = control.floors.get(entity).cloned(); - -let (wrap, new_head) = writer.set_community_metadata( - &my_keys, &community_id, &metadata, head.as_ref(), citation, now_secs).await?; -``` - -`citation` is the `vac` the actor acts under — `None` only for the owner. Build it -from the folded Grant that ranks them (`AuthorityCitation { entity, version, hash }`) -and pass the head from the current fold, so the chain cannot silently fork. - -Wrappers: `set_community_metadata`, `set_channel_metadata`, `set_role`, -`set_grant`, `set_banlist`, `set_registry`, `set_pin_list`, plus raw `publish`. -A ban is a `set_banlist` followed by a base rekey; a kick is a `set_grant` with an -empty `role_ids` followed by `cord02::guestbook::build_kick`. - -## Pins - -```rust -use concord::cord04::pins; - -let entry = cord04::pins::build_entry(&opened_message, &plane, &channel)?; -let head_content = control.pin_content(&community_id, &channel).unwrap_or(""); -let read = cord04::pins::read_list(head_content, |epoch| channel_group_key(&root, &channel, epoch).ok()); -let content = cord04::pins::publishable(&read, channel_is_private, &plane, epoch)?; -let (wrap, _) = writer.set_pin_list( - &my_keys, &community_id, &channel, &content, head, citation, now_secs).await?; -``` - -Reading is verification: `read_list` decodes either content form (public, or -sealed under the channel key at the named epoch), and -`cord04::pins::verify_entry(entry, &channel)` returns a `VerifiedPin` with the proven -author, words and time — no history and no old keys needed. `read.sealed` means -the list is sealed under an epoch this client never held: show it as unavailable, -and never write from it (`publishable` refuses). `cord04::pins::killed_by(&pin, &delete)` -answers whether a folded kind-5 erases an entry. - -## Invites - -```rust -use concord::derive::{invite_bundle_key, TOKEN_LEN}; -use concord::cord05::{self, InviteEntry, InviteTombstone}; - -let token: [u8; TOKEN_LEN] = /* 16 bytes from any CSPRNG */; -let bundle_key = invite_bundle_key(&token); -let link_signer = Keys::generate(); -let bundle = cord05::build_bundle_event(&link_signer, &invite, &bundle_key)?; -let url = cord05::build_invite_url(BASE, &link_signer.public_key(), &token, &relays)?; -``` - -A link is a coordinate plus a fragment: the naddr fetches the bundle, the token -unlocks it, and the fragment names the relays to fetch from. -`cord05::stock_relays()` is what a fragment with no relays of its own means. - -The `link_signer` secret is what lets the creator refresh or retire the link, so -keep it against the token in the member's own Invite List — a local document -encrypted to self, exactly like the Community List: - -```rust -let mut list = cord05::parse_invite_list(&my_keys, &event).await?; -list.entries.push(InviteEntry { - token: HEXLOWER.encode(&token), - signer_sk: link_signer.secret_key().to_secret_hex(), - community_id: invite.community_id, - url, - label: None, - created_at: now_ms, - expires_at: None, - extra: Default::default(), -}); -let event = cord05::build_invite_list(&my_keys, &list).await?; // kind 13303 - -// Retiring is a tombstone, never a deletion: it beats a stale copy terminally. -list.tombstones.push(InviteTombstone { - token: HEXLOWER.encode(&token), - community_id: invite.community_id, - extra: Default::default(), -}); -``` - -`merge_invite_lists` merges two devices' copies, `is_live(&token_hex)` answers -whether a link still stands, and `fits()` is the write gate. - -## Rekeys, refounding and dissolution - -A rotation is authority plus delivery: `rekey_authorized(&control.roles, &owner, &me, permission, &removed)` -gates it, `plan_rotation(scope, epoch)` mints what it delivers, and -`build_rekey_chunks` seals one blob per remaining member: - -```rust -use concord::derive::epoch_key_commitment; -use concord::cord06::{self, RekeyScope, RotationPlan}; - -let scope = RekeyScope::Channel(channel_id); // or RekeyScope::Base -let plan = cord06::plan_rotation(scope, Epoch(epoch + 1))?; -let new_key = plan.new_key(); - -// A base rotation delivers the new control-plane keys beside the root; a channel -// rotation delivers only that channel's fresh key. -let (control_pk, control_root) = match &plan { - RotationPlan::Base(refounding) => { - let pk = refounding.signer(&community_id)?.pk().to_bytes(); - (Some(pk), is_staff.then_some(&refounding.new_control_root)) - } - RotationPlan::Channel { .. } => (None, None), -}; - -let mut blobs = Vec::with_capacity(members.len()); - -for member in &members { - blobs.push( - cord06::build_blob( - &my_keys, - member, - scope, - plan.epoch(), - &new_key, - control_pk.as_ref(), - control_root, - ) - .await?, - ); -} - -let rekey_group = cord06::rekey_group(scope, &community_root, &community_id, plan.epoch())?; -let wraps = cord06::build_rekey_chunks( - &my_keys, - &rekey_group, - scope, - plan.epoch(), - Epoch(epoch), - &epoch_key_commitment(Epoch(epoch), &community_root), - &blobs, - citation, - false, - now_secs, -) -.await?; -``` - -On the receiving side, `cord06::parse_rekey_chunk(&opened)` per wrap, then -`collect_rotations(&chunks)`, then `am_i_removed(&rotation, &me)` — which is -`None` until every chunk is held, because an incomplete set is never a removal. A -member finds their delivery with `find_my_blobs` / `open_blob`, and adopts the key -only if the plaintext binds to the scope and epoch they expect and its `prevcommit` -matches the key they already hold. Two concurrent rotations settle on `fork_winner`. - -`community::rekey::adopt` is that receiver, run against the local database: it -walks a scope forward one epoch at a time off the key actually held (never -waiving a gap, never adopting a fork), keeps each stepped-off key as a prior -with the rotation's publish time as its read cutoff, and reports a removal or a -strand when a complete rotation carries no blob for the member. - -`community::rekey::rotate` is the sender, run against the held state: it resolves -the epoch and key the scope is stepping off, refuses a rotation that would skip or -cut off its own rotator, delivers one blob per recipient (`Rewrite.recipients`, -which the caller names because a private channel's audience is not in the local -state), marks the rotation severed when it excludes somebody, and — for a base -scope — carries the settled Control Plane heads onto the new epoch's groups with -`cord06::compact`. `Community::rotate` checks `Rewrite::authorized` first, the same -`rekey_authorized` under the same permissions `adopt` applies, publishes to the -community's relays, and then adopts what it published through `rekey::adopt` -rather than by construction: a rotation this client wrote and one it received take -one path. - -The blob plaintext is a fixed-width binary record, but a signer's NIP-44 is -text-only, so `build_blob` carries it base64-encoded inside the envelope. -`open_blob` mirrors that, so the record layout and the `locator` are unchanged. - -Dissolution is owner-only and terminal: - -```rust -let rumor = cord06::dissolved_tombstone_rumor(owner_pk, &community_id, now_secs); -let wrap = cord06::seal_dissolved(&rumor, &community_id, &my_keys, now_secs).await?; - -// A receiver seals the community read-only on sight. -if cord06::verify_dissolved(&wrap, &identity) { - state.dissolved = true; -} -``` - -## The Community List - -A member's own memberships, synced across their devices: - -```rust -use concord::cord02::list; - -let entry = concord::state::list_entry(&state, &metadata.name); // state → §8 material -let held = list::parse_list_event(&my_keys, &event).await?; // validates the d tag -let mine = held.joined(entry); // community_id-keyed union -let event = list::build_list_event(&my_keys, &mine, 0, now_secs).await?; // kind 33302, d = 0 -client.send_event(&event).to_nip65().await?; // account's own write relays -``` - -The publish needs no separate database write: `send_event` persists the event -locally *before* it resolves targets, so the fragment is available to the -`concord/list` read path even if every relay is unreachable. - -`join_material(&invite, control_root)` makes the §8 material from a CORD-05 -invite; `concord::state::list_entry(state, name)` makes it from a `CommunityState`, and is -what a write path uses after a create, join or rename. `joined` and `tombstoned` -are the two mutations: both are `community_id`-keyed unions, so neither an append -nor a leave can lose a membership the other writer has. - -The entry is signed by the member's real key and sealed to that same key -(`seal_to_self`), so only the member's devices read it — a stranger's -`parse_list_event` fails rather than returning a partial list. Publishing goes to -the member's **NIP-65 write relays**, the same set the `concord/list` -subscription resolves for its `author` filter. - -Kind `33302` is **addressable and fragmented**: one event per fragment, its `d` -tag the fragment index in decimal. `frags` in the payload declares how many the -List has, and `is_complete(held_indices)` answers whether the client has a -fragment at every index below it. `merge` resolves a `frags` disagreement to the -larger value. (`13302`, the single-event List, is retired by the spec — a -replaceable kind cannot fragment.) - -A join material may also carry two hex extensions this crate does not write but -does read, because they are the only place a device that never held a rotation can -learn who minted an epoch: `refounder` names the npub whose Refounding minted the -entry's `root_epoch`, and `held_roots` is the retained prior epochs -(`[{epoch, key, refounder?, control_pk?, retired_at?}]`). `JoinMaterial::refounder()` -and `JoinMaterial::held_roots()` decode both; unknown fields are round-tripped -regardless, so a document that carries them keeps them. - -The payload's 32-byte values are **unpadded base64url at every depth**, which is -section-scoped to §8: CORD-05 invites stay hex. The writer re-encodes them on -every serialization, so its output is always the canonical 43-character spelling; -the reader also accepts non-zero trailing bits, because the spec's own worked -example contains them and no reader can tell a mis-encoded named field from a -correct one. The codec is `utils::base64url` and the wire structs behind the -List's `Serialize`/`Deserialize` are the only callers, so no other encoding path -is touched. - -Three write-time rules are folded into serialization, so an in-memory document -and its wire form differ: - -- an embedded snapshot omits `community_id` and inherits the entry's; -- `seed` is omitted when it equals `current`, and its cosmetic fields (`name`, - `relays`, each channel's `name`) are overwritten from `current` first, so a - rename collapses the snapshots instead of forking them; -- an entry whose `added_at` does not outrun its tombstone is omitted — the - tombstone alone carries the state. - -`is_live(&id)` answers joined-versus-left: a tombstone is terminal until a -strictly newer join outruns it. `fits()` is the write gate: 50 memberships and -the NIP-44 plaintext cap. The 50 is a stopgap inherited from the retired -single-event design — §8 has **no membership limit**, its only bound is the -65,536-byte encoded event, and the real fix is to start a new fragment on write. -Until that lands, an append onto a List that already spans more than one fragment -is refused rather than performed against a partial read, because placing a new -membership needs a repack. The community stays local (`load` keeps a membership -the List never mentions) and the write is deferred with a warning. - -Discovery is a **subscription, not a fetch**: subscribe with -`Filter::new().kind(Kind::Custom(KIND_COMMUNITY_LIST)).author(my_pk)` and read the -fragments back out of `client.database()`. The client persists a relay's event -before it notifies, so a subscription plus a database read loses nothing and -needs no explicit save. Parse each event with `parse_list_event`, keep the newest -per `fragment_index`, and `merge` them — reading an incomplete List is safe, since -a missing fragment is only news not yet heard. - -## GPUI integration - -`crates/concord` stays GPUI-free; the registry and sync engine live in -`crates/community`. That 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(cx: &mut App) { - CommunityRegistry::set_global(cx.new(CommunityRegistry::new), cx); -} - -impl CommunityRegistry { - pub fn global(cx: &App) -> Entity { - cx.global::().0.clone() - } -} -``` - -Call it after `cord03::init` in `desktop/src/main.rs` and `web/src/lib.rs`, and -subscribe to `NostrRegistry` for `SignerChanged` so the communities reset with -the account. - -- `CommunityRegistry` 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 client = client.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) = cord03::open(wrap, &plane.group, &plane.channel, plane.epoch)?; - cache::cache_rumor(&client, &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(()) -})); -``` - -- Every cache function takes the `&Client` and reaches the database through - `client.database()`, so clone the `Client` into the background task. -- 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. Every account-key writer takes - any signer (`Keys` or the app's `UniversalSigner`) and is `async`, so `await` - it there rather than requiring device keys. - -### 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 for the base, and a **window** of channel epochs under - every held root for private channels: `base_rekey_group_key(&root_N, &id, - Epoch(N + 1))`, plus `channel_rekey_group_key(&root, &channel, Epoch(channel_epoch - + ahead))` for `ahead in 1..=8` and each root in `state.roots()`. A Refounding - seals its channel rekeys under the root current when it was minted, so a member - who adopted the base rotation first must still ask under the prior root; the - window is what lets a member who missed a rotation catch up, or learn they were - cut. This is `community::rekey::watches`, installed as a second standing REQ - whose id resolves back to its community through `rekey::WatchRegistry` (the id - cannot carry a 64-hex community id within the NIP-01 length cap). -- `community::rekey::adopt` then reads those wraps **from the database** and walks - each scope forward one epoch at a time: a complete, authorized rotation whose - `prevcommit` extends the key actually held hands over the next key (raced - rotations settle on the lowest); a gap is fetched, never waived; a fork is never - adopted. An addressed-but-unverifiable rotation is neither adopted nor read as a - removal. Only a complete rotation at/after the join, from a rotator who outranks - the member, with no blob for them, is a removal; one that predates the join is a - strand — a stale invite landed the member on a superseded epoch. - -### 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 - -- **`crates/concord` stays protocol-only; the registry lives in - `crates/community`.** `concord` has no subscriptions, no `init`, and no - `Entity`; `community::CommunityRegistry` owns one `Entity` - per state document, subscribes when a community's plane set changes, and - re-folds on an inbound wrap. The sidebar subscribes to the registry, surfaces - `CommunityEvent::Error` as a window notification, and its "New community" row - in the Communities tab dispatches `Command::NewCommunity`, whose name prompt - calls `CommunityRegistry::create`. `create` persists the - genesis locally, publishes the wraps to the community's relays, and records the - membership in the account's Community List — all best-effort, so a relay that is - down warns without losing the community. Discovery - subscribes to the account's CORD-02 Community List (`33302`) under the - `concord/list` subscription id and reads the fragments back out of - `client.database()` — the SDK persists a relay's event before notifying, so the - read is always current. A `concord/list` notification re-runs `load`, which - materializes a community from each live List entry (`from_join_material`) and - keeps any state document the List does not mention, so a fresh install — or one - signing in as an account that joined elsewhere — finds its communities. See - `docs/concord-community-discovery-plan.md` (including its note on the retired - `13302` the current reference client still writes). -- **Account-key writers take any signer, not `&Keys`.** `genesis`, - `ControlWriter`, the guestbook and chat `seal_rumor`s, the `list` builders, and - the `cord05` invite writers (`build_direct_invite` / `unwrap_direct_invite`, - `build_invite_list` / `parse_invite_list`) and `cord06` blob writers - (`build_blob` / `open_blob`) are `async` and generic over the SDK's - `AsyncGetPublicKey` / `AsyncSignEvent` / `AsyncNip44` traits, so a `Keys` and an - app `UniversalSigner` both work. The NIP-59 paths (`build_direct_invite`, - `unwrap_direct_invite`) stay `Sized` because the SDK's gift-wrap helpers are. - Group-key and locally-held-secret writers (`cord01` wrap functions, - `cord05::build_bundle_event`, `community::cache`) still take the raw key material they - genuinely need. -- **`crates/chat/src/lib.rs::handle_notifications` treats every kind 1059 event as - a NIP-59 gift wrap for the current user.** Concord wraps are kind 1059 too, so - that handler must route by subscription id before any concord subscription goes - live, or every stream wrap lands in the DM trash and raises a toast. -- **Rotation-delivered plane keys are persisted, and history spans them.** - `CommunityState.held_roots` keeps every root a rotation stepped off (with the - retired root's Control signer and the publish time that retires it), and each - `ChannelKeyRef.priors` keeps every channel key a rotation stepped off. The read - side derives planes from all of them, and a retired key's `retired_at` is a hard - read cutoff at both `history::page` and `sync::fold`. The state - `Community::removed_at`/`Community::stranded`/`channel_removed_at` carries is - rendered as a notice **and** enforced at write time: `Community::send` refuses - when `channel_secret` is `None` (a removal, a strand, a channel cut, or a key we - never held), because a wrap sealed under a superseded root would reach nobody who - rotated. A rotation this client publishes itself (`Community::rotate`) is adopted - through the same `rekey::adopt` an arriving one goes through, so the held state - and the wire cannot diverge. diff --git a/docs/nip29-backend-plan.md b/docs/nip29-backend-plan.md deleted file mode 100644 index 77107bcc..00000000 --- a/docs/nip29-backend-plan.md +++ /dev/null @@ -1,650 +0,0 @@ -# NIP-29 groups backend - -Coop has two relay-backed backends today: `crates/chat` (NIP-17 gift-wrap DMs, -rendered by `crates/chat_ui`) and `crates/community` (Concord). This document is a -plan, not a description of shipped code; nothing below is wired up yet. It covers -the backend only — reading, writing, joining and leaving relay-based groups as -defined by [NIP-29](https://github.com/nostr-protocol/nips/blob/master/29.md) — -and ends with the hooks a UI will use later. - -## The spec in backend terms - -A group is a `(relay, id)` pair. The relay enforces membership and signs the -group's state; there is no cross-relay identity, no encryption, and no portable -"create". The same `id` on two relays is two groups (a fork), which the plan -models as two entities. - -| Concern | Wire | Backend rule | -| --- | --- | --- | -| Group id | random string, relay-chosen | `h` tag on everything a user sends; `d` tag on relay state events | -| Timeline | any event kind + `h` | v1 renders kinds `1`, `9`, `11`, `1111` and `7`; every other kind is cached for the `previous` window but not rendered, except the membership kinds below | -| Timeline refs | `previous` tag, first 8 hex chars of event ids | built from the last 50 events seen on that relay (any kind — NIP-29 timeline references), excluding our own; send up to 3 | -| Relay state | `39000` metadata, `39001` admins, `39002` members, `39003` roles, `39004` LiveKit participants | relay-signed, addressable by `d`; read, never written by the client | -| Membership | `9021` join, `9022` leave (user-signed); relay answers with `9000` put-user / `9001` remove-user | membership = latest `9000`/`9001` naming our pubkey; no event means not a member, and `Pending`/`Refused` are tracked locally for private groups | -| Own group list | `10009` (NIP-51 simple groups) | `["group", id, relay, name?]` plus `["r", relay]`; replaceable, published to NIP-65 relays | -| Reference | `naddr1…` of kind `39000` with the relay self pubkey and a relay hint, optionally `?invite=` | the self pubkey is the author of the fetched `39000`; no `39000` means no canonical naddr | - -Two behaviours that shape the code: - -- **Late publication is rejected.** Relays drop messages with old timestamps, so - there is no offline send queue in v1: a send either goes now or fails visibly. -- **`previous` must reference events the relay already has.** Relays may enforce - at least 3 references; a group we have little history for may have its sends - rejected until the backlog loads. The relay's `OK false` message is surfaced - as-is. - -### Not in v1 - -Moderation writes (`9000`–`9010`), group creation (`9007`, relay-dependent), -pinned events (`39005`/`9010`), subgroups (`parent`/`child` — parsed but inert), -LiveKit audio/video (`livekit` tag, `39004`, the token endpoint), fork/migration -detection through admins' `10009`, and relay browsing (`{"kinds":[39000]}` to -list a relay's public groups). All are additive; none change the model above. - -## What the local rust-nostr checkout gives us - -Cross-checked against the pinned checkout -(`~/.cargo/git/checkouts/nostr-619b808bb247a9ed/01f1950`): - -- **Kinds exist** for every NIP-29 event in v1: `Kind::GroupPutUser` (9000), - `GroupRemoveUser` (9001), `GroupEditMetadata` (9002), `GroupDeleteEvent` - (9005), `GroupCreateGroup` (9007), `GroupDeleteGroup` (9008), - `GroupCreateInvite` (9009), `GroupJoinRequest` (9021), `GroupLeaveRequest` - (9022), `GroupMetadata` (39000), `GroupAdmins` (39001), `GroupMembers` - (39002), `GroupRoles` (39003), `GroupLivekitParticipants` (39004), and - `Kind::SimpleGroups` (10009). `Kind::ChatMessage` is kind 9. -- **`9010` and `39005` are missing** from this revision (`Kind::Custom(9010)`, - `Kind::Custom(39005)` if they are ever needed — they are not, in v1). -- **There is no NIP-29 module.** `nostr/src/nips/` has no `nip29.rs`, and no - `GroupId`, metadata type, parser or builder exists. Everything in `protocol.rs` - below is ours; it is thin — tags and filters, no crypto. -- **The APIs to lean on** are all present and already used in the app: - - `Tag::custom`, `Tag::event`, `Tag::identifier`, `Tag::public_key`, - `Tag::content`, `Tag::as_slice`. - - `Filter::custom_tag(SingleLetterTag::LOWERCASE_H, id)`, - `.identifier(id)`, `.author`, `.kinds`, `.limit`, `.since`. - - `ReqTarget::single(url, filters)` for a group-scoped REQ, - `client.subscribe(..).with_id(..)`, `client.fetch_events(..)`, - `client.send_event(&event).to([url])`. - - `EventBuilder::new(kind, content).tags(tags).finalize_async(&signer)` — the - app's `UniversalSigner` works here (NIP-46 included), as it does in - `crates/chat/src/room.rs`. - - `client.database().query/save_event/delete`. - - `Nip19Coordinate { coordinate, relays }` with `ToBech32`/`FromBech32` for - `naddr`. -- **NIP-11 is not exposed.** `Relay` in this SDK has no document accessor, so the - relay self pubkey is taken from the author of the group's `39000` event; a - group whose relay publishes no `39000` therefore has no canonical naddr and - cannot be shared or joined in v1. - -## What the app already does (and we copy) - -The NIP-17 backend is the template. `crates/chat` has a global `ChatRegistry` -entity, one `Room` entity per conversation, a background task over -`client.notifications()` that sends signals through a `flume` channel, and a -foreground `cx.spawn` consumer that is the only place entities change. Local -messages are cached as `ApplicationSpecificData` (30078) events with a `d` tag -for the target id and an `r` tag for the room key (`set_rumor`/`get_rumor`, -`crates/chat/src/lib.rs`). `crates/chat_ui` renders `chat::Message` into a -`ListState`, resolving names through `PersonRegistry`. - -`crates/community` shows the same pattern for relay-scoped state: routing -notifications by subscription id (`route_of`), bringing relays into the pool -before use (`connect_relays`), and treating a `CLOSED` message as a refusal. -Replacing a list is done in `crates/workspace/src/panels/contact_list.rs`: -sign the builder, `client.send_event(&event).to_nip65().await` — the client saves -the event locally even when the publish fails. - -NIP-29 reuses all of this and drops what only NIP-17 needs: no encryption, no -per-member fan-out, no gift wraps, no `SendReport` per receiver — one relay, one -event, one `OK`. - -## Where this lands - -A new crate `crates/nip29`, because NIP-29 is a different transport (plain relay -events), a different state authority (relay-signed `39000`s) and a different UI -later. Making `chat::Room` polymorphic over gift-wrapped DMs and relay groups -would put two protocols in one entity. The alternative — modules inside -`crates/chat` — saves a `Cargo.toml` but was set aside (see Decisions). - -| File | Owns | -| --- | --- | -| `crates/nip29/src/protocol.rs` | Pure NIP-29 types and their `impl` blocks: `GroupId`, `GroupKey`, `GroupReference`, `GroupMetadata`, admins/members/roles, `GroupList`, `Membership`, `TimelineWindow`. No I/O. | -| `crates/nip29/src/group.rs` | `Group` entity: state, timeline, membership, `connect`, `ingest`, `send_message`, `send_reaction`, `join`, `leave`, `GroupEvent`. | -| `crates/nip29/src/lib.rs` | `GroupsRegistry` global, `init`, standing subscriptions and the signal pump, the envelope cache, `load`/`publish` of the account's `10009`. | -| `desktop/src/main.rs`, `web/src/lib.rs` | `nip29::init(cx)` after `chat::init(cx)`. | -| UI later | `crates/chat_ui` (reuse `chat::Message` rendering) or a new `crates/nip29_ui`; sidebar section; a join dialog. | - -`protocol` is re-exported at the crate root (`pub use protocol::*`) so callers -write `nip29::GroupId`, `nip29::GroupMetadata`. The crate's dependencies are the -ones `chat` already carries: `state` (client and signer), `chat` (`Message`), -`gpui`, `nostr-sdk`, `anyhow`, `flume`, `futures`, `smallvec`, `log`; -dev-dependencies `nostr-memory` and `nostr-sdk` with the `local-relay` feature -for the tests below. - -## Data flow - -``` -own 10009 (NIP-65 relays) group relay (one per group) - │ load / subscribe │ REQ #h= (timeline + 9000/9001) - ▼ │ REQ #d= (39000..39003) -GroupsRegistry ──ensure relay──► Group entity ◄──ingest── background listener - │ index: GroupKey │ (saves envelope first) - │ │ - └── flume Signal ◄──────────────────────┘ - ▲ - UI later: GroupPanel (Vec), JoinDialog (naddr) -``` - -A group's subscriptions are the standing `grp-` REQ (timeline + state + -membership, the timeline replayed up to 100 events) and, on demand, a -`pg-` REQ that re-asks the same timeline filter with `until` set to the -oldest event hydrated so far. Both are recognised by the same pump and land in -the same envelope cache, so paging needs no separate read path. - -## Phase 0 — `crates/nip29/src/protocol.rs` (pure, no I/O) - -The protocol is expressed as types with `impl` blocks: a parsed thing is a struct -and every operation on it is a method, so call sites read `id.message(...)`, -`metadata.naddr(...)`, `admins.contains(&pk)` instead of handing the pieces to -free builder functions. - -```rust -/// The relay-chosen group id. -pub struct GroupId(String); - -impl GroupId { - pub fn new(id: impl Into) -> Result; // trimmed, non-empty - pub fn as_str(&self) -> &str; - pub fn h_tag(&self) -> Tag; // ["h", id] - - pub fn timeline_filter(&self, limit: usize) -> Filter; // #h = id - pub fn state_filter(&self) -> Filter; // #d = id, kinds 39000..39003 - pub fn membership_filter(&self, me: PublicKey) -> Filter; // #h = id, #p = me, kinds 9000/9001 - - pub fn message(&self, content: String, replies: &[EventId], previous: Option) -> EventBuilder; // kind 9 - pub fn reaction(&self, target: EventId, emoji: String, previous: Option) -> EventBuilder; // kind 7 + e - pub fn join_request(&self, code: Option<&str>, reason: &str) -> EventBuilder; // 9021 - pub fn leave_request(&self, reason: &str) -> EventBuilder; // 9022 -} - -/// Entity identity: the same id on another relay is another group (a fork). -pub struct GroupKey { relay: RelayUrl, id: GroupId } - -impl GroupKey { - pub fn new(relay: RelayUrl, id: GroupId) -> Self; - pub fn relay(&self) -> &RelayUrl; - pub fn id(&self) -> &GroupId; - pub fn cache_tag(&self) -> String; // "|" - pub fn uniq_id(&self) -> u64; // hashed, for panel and element ids -} - -/// What a user pastes to join: naddr1…(+"?invite="). -pub struct GroupReference { id: GroupId, relay: RelayUrl, invite: Option } - -impl GroupReference { - pub fn parse(input: &str) -> Result; - pub fn id(&self) -> &GroupId; - pub fn relay(&self) -> &RelayUrl; - pub fn invite(&self) -> Option<&str>; - pub fn key(&self) -> GroupKey; -} - -impl FromStr for GroupReference { .. } // so a pasted naddr can use `.parse()` too - -/// kind:39000, relay-signed. -pub struct GroupMetadata { - pub name: Option, pub picture: Option, pub banner: Option, - pub about: Option, pub private: bool, pub restricted: bool, pub hidden: bool, - pub closed: bool, pub livekit: bool, pub parent: Option, - pub supported_kinds: Option>, // None = unspecified (all); Some(vec![]) = none -} - -impl GroupMetadata { - pub fn parse(event: &Event) -> Result; - pub fn name(&self) -> Option<&str>; - pub fn supports(&self, kind: Kind) -> bool; - pub fn naddr(&self, relay: &RelayUrl, invite: Option<&str>) -> Result; - // Coordinate { kind: 39000, public_key: , identifier: } -} - -/// kind:39001 — each pubkey with its role labels. -pub struct GroupAdmins(Vec<(PublicKey, Vec)>); - -impl GroupAdmins { - pub fn parse(event: &Event) -> Result; - pub fn entries(&self) -> &[(PublicKey, Vec)]; - pub fn contains(&self, public_key: &PublicKey) -> bool; -} - -/// kind:39002 — may be absent or a subset; absence means "not published". -pub struct GroupMembers(Vec); - -impl GroupMembers { - pub fn parse(event: &Event) -> Result; - pub fn contains(&self, public_key: &PublicKey) -> bool; - pub fn iter(&self) -> impl Iterator; -} - -/// kind:39003 — role names are relay-defined. -pub struct GroupRoles(Vec); -pub struct Role { pub name: String, pub description: Option } - -impl GroupRoles { - pub fn parse(event: &Event) -> Result; - pub fn roles(&self) -> &[Role]; -} - -/// kind:39004 — who is live in the group's AV room; `participant` tags, not `p`. -/// Absence means nothing is known, which is not the same as an empty room. -pub struct GroupParticipants(Vec); - -impl GroupParticipants { - pub fn parse(event: &Event) -> Result; - pub fn contains(&self, public_key: &PublicKey) -> bool; - pub fn iter(&self) -> impl Iterator; -} - -/// kind:10009 — the account's own list. -pub struct GroupList(Vec); -pub struct GroupListEntry { pub id: GroupId, pub relay: RelayUrl, pub name: Option } - -impl GroupList { - pub fn parse(event: &Event) -> Result; - pub fn entries(&self) -> &[GroupListEntry]; - pub fn upsert(&mut self, entry: GroupListEntry); - pub fn remove(&mut self, key: &GroupKey); - pub fn to_builder(&self) -> EventBuilder; // kind 10009, group + r tags -} - -pub enum Membership { - Unknown, - Pending { since: Timestamp }, // our 9021 went out; no answer yet - Member, - Refused { reason: Option }, // the relay's OK false / CLOSED message - Removed, -} - -impl Membership { - /// Folds 9000/9001 only; `Pending` and `Refused` are set by the join path. - pub fn from_events(events: &[Event], me: PublicKey) -> Self; -} - -/// The newest-first window (≤50) that `previous` references are drawn from. -pub struct TimelineWindow(Vec); - -impl TimelineWindow { - pub fn new() -> Self; - pub fn push(&mut self, event: Event); // dedups, caps at 50 - pub fn previous_tag(&self, me: PublicKey) -> Option; // excludes `me`, ≤3 distinct 8-hex -} -``` - -Details that must be right: - -- Every user event carries `["h", id]` (from `GroupId::h_tag`); `previous` is - added only when we have at least one eligible event, and its values are the - first 8 characters of the hex ids. -- `GroupReference::parse` splits `?invite=` before bech32 decoding; a missing - relay hint is an error (there is nowhere to send the join request). -- `GroupList::parse` accepts `group` tags with 1–3 values (id, relay, name). -- `Membership::from_events` takes the newest `9000`/`9001` whose `p` tags name - `me`; on an exact tie it prefers `9001` (removal). `Pending` and `Refused` are - not things the relay folds into an event: the join path sets them from the - `9021` send and from the relay's refusal message. -- `GroupMetadata::supports` distinguishes "tag absent" from "tag present with no - values": the first means all kinds, the second means no text messages. -- The builders return `EventBuilder`s so the caller decides when to sign; nothing - in this file does I/O. - -## Phase 1 — read path (`group.rs` + `lib.rs`) - -`GroupsRegistry` (global entity, mirroring `CommunityRegistry`): - -```rust -pub struct GroupsRegistry { - groups: Vec>, - index: HashMap>, - /// The account's 10009, loaded at start and mutated by join/leave - list: GroupList, - /// subscription id → group, rebuilt whenever a group resubscribes - routes: Arc>>, - signal_tx: flume::Sender, signal_rx: flume::Receiver, - notification_listener: Option>>, // cancelled on signer change - signal_consumer: Option>>, - tasks: SmallVec<[Task>; 2]>, - _subscriptions: SmallVec<[Subscription; 2]>, -} - -impl GroupsRegistry { - pub fn global(cx: &App) -> Entity; - pub fn groups(&self) -> &[Entity]; - pub fn group(&self, key: &GroupKey) -> Option>; - pub fn join(&mut self, reference: GroupReference, cx: &mut Context); - pub fn leave(&mut self, key: &GroupKey, cx: &mut Context); - fn load(&mut self, cx); // own 10009 from the database into `self.list` - fn publish_list(&mut self, cx); // sign `self.list.to_builder()`, to_nip65, best-effort -} -``` - -- **Subscriptions.** One standing REQ per group, two filters in one id: - `key.id().timeline_filter(100)` and `key.id().state_filter()`. The id is - `grp-<8 hex of the group key hash>` (NIP-01 caps ids at 64 chars; the real key - lives in `routes`). The relay is added and connected first - (`add_relay(url).and_connect()`), like `community::sync::connect_relays`. On - open, also issue a one-shot `key.id().membership_filter(me)` fetch so a restart - learns `9000`/`9001` immediately; the state filter is fetched once (limit 1 per - kind) so the sidebar has names before the live REQ settles. A dead relay costs - only its own group. -- **Pump.** The listener matches `ClientNotification::Message` and routes - `RelayMessage::Event`/`EndOfStoredEvents`/`Closed` by subscription id through - `routes`. For `h`-tagged events it saves the envelope first, then sends - `Signal::Event(key, event)`; state events send `Signal::State(key, event)`; - `Closed` sends `Signal::Refused(key, message)`. The consumer calls - `Group::ingest`, which is the only place entity state changes. -- **Envelope cache.** Same shape as `set_rumor`/`get_rumor` in `crates/chat`: - `ApplicationSpecificData` (30078, addressable) with `d` = event id (dedup on - replay), `r` = `"|"` (per-fork history), `k` = kind, content = the - raw event JSON, signed by a crate-local `LOCAL_KEYS`. Every `h`-tagged event - the standing subscription delivers is enveloped — any kind, because the - `previous` window is literally "the last 50 events seen" (NIP-29 timeline - references) — while the `d`-tagged state events (`39000`–`39003`) are not. - Reading is `Filter::new().custom_tag(LOWERCASE_R, key)`, parsed and sorted by - `created_at`, then partitioned: the render set (`1`, `7`, `9`, `11`, `1111`) - becomes messages, `9000`/`9001` fold membership, the rest only feed the - window. This is what makes the timeline fork-safe and restart-safe without - teaching the database about relays. -- **`Group` state.** `GroupKey`, `metadata: Option`, - `metadata_event: Option` (what `GroupMetadata::naddr` is built from), - `members`, `admins`, `roles`, `membership: Membership` (including the local - `Pending`/`Refused` access states), `messages: Vec` for the render set - (reusing `chat::Message`, which already parses any event into display fields), - an id index, a `TimelineWindow` for `previous`, `loading`, `refused`. It emits - `GroupEvent::{Incoming(Event), Updated, Refused(String), Eose}` — the panel - subscribes to these the way `ChatPanel` subscribes to `RoomEvent`. -- **`load()`.** Subscribe to our own `10009` (`ReqTarget::auto`, limit 1), then - read the newest one from the database into `self.list`, and add one group per - entry. A newer `10009` arriving from another device is merged - (`GroupList::upsert`) and the new groups are subscribed; removals by another - client cannot be detected in a replaceable list and are not guessed at. - -## Phase 2 — write path - -All writes sign with the app signer (`finalize_async(&signer)`), send to the -group's relay only, and treat the relay's `OK` as the truth. - -- **Send a message.** Build with - `key.id().message(content, &replies, window.previous_tag(me))`, sign, then - `client.send_event(&event).to([relay])`. Kind 9 is the default message kind; - when `metadata.supports(Kind::ChatMessage)` is false the composer is disabled - rather than sending a kind the relay does not take. On acceptance: save the - envelope, insert into `messages`, emit `GroupEvent::Incoming`. On `OK false`: - return the relay's message (it already carries `invalid:` / `restricted:` / - `duplicate:` prefixes) and let the panel show it. Reactions go through the same - path with `key.id().reaction(..)`. -- **Join.** `GroupReference::parse` → error if no relay hint → add/connect the - relay → fetch `39000` (`limit 1`) to confirm the group and learn the self - pubkey; `GroupMetadata::parse` it; a hidden group may refuse this — proceed - without metadata rather than failing → create the entity, fetch state + - membership, subscribe → send `key.id().join_request(code, ..)` — the `h` tag - plus the `code` tag when an invite code was parsed → mark - `Membership::Pending { since }` and `self.list.upsert(..)`, so the group stays - listed while it waits. The relay either emits `9000` (member), refuses the - `9021` with `OK false` (set `Refused { reason }` from its message — the NIP - says the message explains whether the rejection is final, pending review or - payment-required), or refuses the timeline REQ with `restricted:` / - `auth-required:` in a `CLOSED` (keep the state, do not raise an error toast). - All three are observed on the standing subscription or the send result. -- **Leave.** Send `key.id().leave_request(..)`; on `OK`, forget the group's - envelopes (`client.database().delete(filter)` by `r` tag), drop the entity and - `self.list.remove(key)`, then publish. The relay also emits `9001`, which the - (now gone) subscription would have seen. -- **Publish `10009`.** Sign `self.list.to_builder()` and publish with - `to_nip65()`. The registry's `GroupList` is the one loaded at start, join - `upsert`s an entry and leave `remove`s one, so a rewrite can never drop a group - we loaded. The client saves the event locally even when the publish fails, so - the list survives offline. - -## Phase 3 — later, in rough order - -1. Moderation writes: `9000` put-user (roles), `9001` remove-user, `9002` - edit-metadata, `9005` delete-event, `9009` create-invite; gate the UI on the - caller appearing in `39001`, let the relay be the enforcer. **Done** as pure - writes: builders on `GroupId`, senders on `Group` sharing `dispatch`. - `edit_metadata` takes a `GroupMetadata`, which is now `Default`-able (its - private `id`/`author` origin are `Option`) so the UI can start an edit from - scratch or from `Group::metadata()`. **`9005` is write-only for now**: the - relay stops serving the event but nothing is removed locally, so the deleted - message lingers in the cache and timeline. The UI phase must drop the target - envelope (filter on its `d` = target id and the group's `r` cache tag) and - evict it from `messages`/`index`, otherwise only a reload clears it. -2. Group creation (`9007`) where the relay supports it, and relay browsing. - **Done.** `GroupId::create_group` reuses the `9002` metadata tags, so the - proposed id (`h`) and the initial name/flags go in one event; the relay - republishes `39000`, which the standing subscription picks up. - `GroupsRegistry::create` mirrors `join`: `ensure` → optimistic `10009` entry - + `publish_list` → publish, and on `OK false` it `forget`s the group and emits - `GroupsEvent::Error(reason)` (that variant was previously unused). There is - no NIP-11 support check available, so "where the relay supports it" is - enforced by the relay refusing the `9007`. - `GroupsRegistry::browse(relay, cx)` follows the same pipeline as every - other read: it registers a `brw-` subscription (an opaque id, like - `grp-`; the relay is keyed in state, not encoded in the id, because the - pump needs a prefix to recognise its own ids and a bare - `SubscriptionId::generate()` would be indistinguishable from the other - crates' ids on the shared client). `add_relay` + - `subscribe` (auto-close on EOSE, 10s cap), and returns immediately. The - **global pump** observes the events, and `handle_event`/`handle_eose`/ - `handle_closed` fold each relay-signed `39000` into `self.browse`, keyed by - the subscription id, so no DB scoping is needed. `GroupsRegistry::browsed(relay)` - returns `Browse { relay, loading, groups: Vec }` for the UI, and - `GroupsEvent::Updated` says when to re-read it. No `fetch_events` anywhere: - the DB cannot be scoped to a relay (a stored event carries no origin), so the - subscription's own events are the source. `join` now delegates to a new - `join_key(key, invite)`, which is what a browse result needs (a candidate has - a key, not a `GroupReference`). -3. Pins and subgroups. **Pins done:** `Pin::{Event, Address}` and - `GroupPins` in `protocol.rs`, with `PIN_LIST` (39005) and `UPDATE_PIN_LIST` - (9010) as `u16` consts used through `Kind::Custom`. `39005` joins - `STATE_KINDS`, so the standing subscription fetches it, `absorb_state` folds - it into `Group::pins()`, and `Group::update_pins(pins)` writes `9010` through - the shared `dispatch`. **Subgroups parsed:** `GroupMetadata` gained - `pub children: Vec` (order preserved, parsed from `child` tags) and - `metadata_tags` re-emits them — `9002` requires it, an edit that drops a - child is rejected. `parent`/`children` are per-relay, so assembling the tree - from `Group::metadata()` is UI work; there is no backend tree helper. -4. Range backfill: page the timeline with `until` for "load more". **Done.** - `GroupId::timeline_page(limit, until)` is `timeline_filter` plus `.until(..)`, - and `Group::load_page` sends it as a `pg-` subscription (auto-close on - EOSE, 10s cap) alongside the standing `grp-` one, which keeps running so - paging cannot replace the live tail. The read path is the usual one: `pg-` - joins `is_own_subscription`, the id is registered in `routes` against the same - key, and `handle_event` therefore caches each page event and `reload`s the - group, so it arrives through the envelope cache and the timeline like any - other event. `GroupsRegistry::load_more(key, cx)` is the entry point: it does - nothing while a page is in flight (`Group::loading_more`) or before anything is - cached, and registers the route before `Group::load_page` sets the flag. - `handle_eose` and a page `CLOSED` remove the route and call - `Group::page_finished`, which clears the flag and emits `GroupEvent::Updated` - so a panel's spinner can settle. The cursor is `Group::oldest()`, the oldest - `h`-tagged event ever hydrated, updated by `track` next to `window.push` — - *not* read from `TimelineWindow`, which keeps only the newest 50 events, so a - cursor taken from it would never move and would page the same window forever. - `until` is inclusive, so a page repeats the event it starts from; `hydrate` - dedups by id, so that costs one redundant fetch, never a gap. `forget` also - drops and unsubscribes the `pg-` route (`reset` already sweeps `routes`). Two - gaps left for the UI phase: a relay that accepts the REQ but never EOSEs leaves - `loading_more` true (the auto-close timeout closes the subscription locally and - emits no notification — the same stickiness as `Browse::loading`), and a group - whose next page shares the `until` second cannot advance the cursor, so it can - re-fetch the same page. Neither loops on its own; both need a "load older - messages" button bound to `Group::loading_more()` to be observable at all. -5. LiveKit AV and fork/migration detection, split in three: - 1. **`39004` participants. Done.** `GroupParticipants` joins `protocol.rs` - with the same shape as `GroupMembers` but reads `participant` tags (the NIP - does not use `p` here); one malformed pubkey fails the whole event, as in - `GroupAdmins`. The kind joins `STATE_KINDS`, so the standing subscription - already carries it, and `absorb_state` folds it into `Group::participants()`. - Nothing is written by us — the relay publishes it and updates it as people - come and go, so it rides the live tail like any other state event. - "Absent" and "empty" are deliberately the same value here: unlike - `supported_kinds`, the NIP gives the empty list no special meaning, so the - UI reads "no one is live" either way. - 2. The `/.well-known/nip29/livekit/` token endpoint and the `204` - probe at `/.well-known/nip29/livekit`. **Done.** The HTTP client is the one - GPUI already owns: `cx.http_client()` returning `Arc`, - already wired to `reqwest_client::ReqwestClient` on desktop and to `fetch` - on wasm — the same path `state::nip05` uses, which is why no HTTP - dependency lands in this crate. Its `get()` convenience cannot set headers, - so the request goes through `HttpClient::send` with a `Builder`, an - explicit `RedirectPolicy::NoFollow` (the `Authorization` header is signed - for exactly one URL, so a redirect could only ever fail verification) and a - 10s `timeout` — both are opt-in extensions, and a request without them has - none. The header itself is NIP-98 from the SDK: `nostr/nip98` is now - enabled on the workspace `nostr` entry and nip29 declares `nostr` itself, - so `HttpData::new(url, HttpMethod::GET).to_authorization(&signer)` builds - `Nostr ` — the `u` + `method` tags that the crate's own - `verify_auth_header` demands, though the NIP's prose mentions only `u`. - `GroupKey::livekit_token_url` / `livekit_probe_url` derive the endpoint - purely: the relay URL is rewritten to http(s) (keeping host and port), - reset to the origin, and the path built with `pop_if_empty().push(..)` so - each segment is escaped — a relay with its own path prefix loses it, since - the NIP puts the endpoint at the absolute path. `Group::livekit_session` - and `Group::livekit_supported` are the two calls, both `&App` + - `background_spawn` like `publish`. The session is returned, not stored: - tokens are per-join artifacts, not group state, so the caller owns it. - Only `livekit_supported`'s unauthenticated GET ignores status beyond - `204`; the token endpoint's response shape is **not specified by the NIP** - (see Decisions) and no LiveKit client library is involved — the backend - stops at the JWT and server URL. - 3. Fork/migration detection. **Done.** One standing `nip29-forks` - subscription over `{authors: , kinds: [10009]}` - on `ReqTarget::auto` — the admins' own group lists live on their outbox - relays, so this works with the group's relay down, and the admin pubkeys - come from `39001`, which is hydrated from the envelope cache, so it works - after a restart too. Each event is folded into `forks` (author → newest - `GroupList`, guarded on `created_at` because `10009` is replaceable), then - `evaluate_forks` recomputes every group. `Group::elsewhere()` is `Some` - when an admin of that group lists the same group id at a different relay; - the data cannot distinguish a move from a fork, hence the neutral name. - Evaluation is incremental, on each event, rather than batched on EOSE: - `ReqTarget::auto` fans out to several relays, so the first EOSE would - otherwise evaluate — and clear — against one relay's partial view. - Nothing is auto-joined, since the NIP asks the client to *offer*; the - offer is `GroupsRegistry::join_key(elsewhere, None, cx)`, which is exactly - "fetch from the new relay and update our own `10009`". Two triggers, - because the NIP's "periodically" and its "MUST when the relay is offline" - are different things. Reactively, the group observer calls - `sync_forks(false)` on `GroupEvent::Updated`, subscribing as soon as the - first `39001` lands and re-subscribing only when the admin set actually - changes — the guard matters, since `Updated` also fires for metadata, - pins, membership and a finished page. Periodically, a - `background_executor().timer` loop started in `load` pushes - `Signal::Forks` every 15 minutes and `sync_forks(true)` re-queries - regardless: that is the recovery path for a subscription that died or a - client that was offline, not the detection path. `reset` drops the fork - state and unsubscribes the REQ; a `CLOSED` on it is only logged, because - the tick retries and there is nothing to repair. - -## UI later — the hooks this backend leaves - -| UI action | Call | -| --- | --- | -| Paste an naddr and join (or retry a refused join) | `GroupReference::parse` → `GroupsRegistry::join` | -| Open a group | `GroupsRegistry::group(&key, cx)` → panel subscribes to `GroupEvent` | -| Timeline | `Group::messages()` (already `chat::Message`s) | -| Load older messages | `GroupsRegistry::load_more(&key, cx)`; spinner from `Group::loading_more()` | -| Send / react | `Group::send_message` / `send_reaction` | -| Members, admins, roles, flags | `Group::members/admins/roles/metadata` | -| Who is in the AV room | `Group::participants()` | -| Join AV (get a LiveKit session) | `Group::livekit_session(cx)` → `await` → `LivekitSession { token, server_url }` | -| Is AV available here | `Group::livekit_supported(cx)` after seeing `metadata().livekit` | -| The group may have moved or been forked | `Group::elsewhere()` → offer `GroupsRegistry::join_key(elsewhere, None, cx)` | -| Membership badge (member, pending, refused, removed) | `Group::membership()` | -| Copy an invite link | `GroupMetadata::naddr(relay, invite)` | -| Leave | `GroupsRegistry::leave` | -| Create a group | `GroupMetadata { ..Default::default() }` → `GroupsRegistry::create(key, metadata, cx)`; failure surfaces as `GroupsEvent::Error` | -| Browse a relay's groups | `GroupsRegistry::browse(relay, cx)` → re-read `GroupsRegistry::browsed(relay)` on `GroupsEvent::Updated`; join a result with `GroupsRegistry::join_key(candidate.key, None, cx)` | -| Moderate (put/remove user, edit metadata, delete event, invite) | `Group::put_user/remove_user/edit_metadata/delete_event/create_invite` | -| Pins | `Group::pins()` to read, `Group::update_pins(pins)` to replace the list | -| Subgroup tree | `Group::metadata()` → `parent`/`children`, assembled per relay | - -Where it lands: the sidebar gains a section reading `GroupsRegistry::groups()` -(the same shape as its Communities section) with pending and refused groups -listed alongside members; a new panel renders `Vec` with the existing -`ListState` + `ui::message::MessageRow` and `PersonRegistry` for authors. The -message *data* is already shared (`chat::Message`), so only `ChatPanel`'s -row-rendering methods need lifting into `ui` (or copying) rather than the type. A -join dialog accepts a pasted naddr (with or without `?invite=`). System -notifications can hang off `GroupEvent::Incoming` when the window is inactive. - -Nothing new is needed from GPUI to support this backend. The local checkouts -are `gpui-pre` 0.3.7 (aliased as `gpui`) and `gpui-base` 0.7.0, which `crates/ui` -styles; the GPUI APIs the backend uses are the ones `chat` and `community` -already use — `Entity`/`WeakEntity`, `Context::spawn`, `background_spawn`, -`EventEmitter`, `Subscription` — plus two that `state` already uses: -`App::http_client()` for the AV token fetch and -`App::background_executor().timer(..)` for the fork tick. The panel's components -(`ListState`, `MessageRow`, `Avatar`, `Button`, `Scrollbar`) already exist in the -app. - -## Tests - -- `protocol.rs` unit tests: `GroupReference::parse` (naddr, `?invite=`, garbage, - missing relay hint, an naddr whose kind is not `39000`); `GroupMetadata::parse` - flags and `supports` with `supported_kinds` absent vs empty; admins with roles; - members; roles; `GroupList` round trip (`parse` → `upsert` → `to_builder`); - `TimelineWindow::previous_tag` (excludes self, caps at 3, 8-char prefixes, - any-kind window, `None` when empty); `Membership::from_events` (`9000` vs - `9001`, newest wins, tie prefers removal; `Pending`/`Refused` are exercised - through the join path); `GroupId::message` carries `h` and the reply `e` tags; - `GroupMetadata::naddr` round trip with and without an invite code. -- Cache round trip with `nostr-memory` as a dev-dependency (the pattern - `crates/community` already uses): save two envelopes (a kind 9 and a kind - `9000`), read them back sorted, assert the partition — the kind 9 renders, the - `9000` only folds membership — re-save one and confirm no duplicate, `delete` - clears the group. -- Routing: subscription id ↔ `GroupKey` mapping survives a resubscribe. -- One integration test against the SDK's `LocalRelay` - (`nostr-sdk` dev-dependency with the `local-relay` feature): seed a `39000` - and a kind 9 with an `h` tag, run `Group::connect`, assert metadata and the - timeline land. Timeouts in GPUI-driven tests use - `cx.background_executor().timer(..)`, never `smol::Timer`, per `AGENTS.md`. - -## Decisions - -1. **Crate.** `crates/nip29` (the body above is written for it). -2. **Timeline kinds.** Render `1`, `9`, `11`, `1111` and `7`; every other kind - is still cached and feeds the `previous` window, per the NIP's timeline - references rule. -3. **Default message kind.** Kind 9; the composer is disabled when - `supported_kinds` is present and lacks it. -4. **Pending joins.** Pending (and refused) groups are listed in the sidebar, - with an access state on the row. -5. **`10009` merge.** Union-only; removals made by another client are not - detected and no diffing is done. -6. **Leave and the cache.** Leaving wipes the group's envelopes. -7. **Restricted/private groups.** A visible access state is required: - `Membership::{Unknown, Pending, Member, Refused, Removed}` with the relay's - refusal reason shown and a retry, while the client's NIP-42 authenticator - handles the reads. -8. **References.** `naddr` only (`?invite=` accepted); `id@relay` is not. A group - whose relay publishes no `39000` cannot be referenced or joined in v1. -9. **LiveKit response shape.** The NIP says the token endpoint returns "the - LiveKit JWT and the URL of the LiveKit server" but names no fields, so - `LivekitSession::parse` reads `token` (or `jwt`) and `url` (or `server_url`) - and fails loudly on anything else. This is a guess, not a spec, and it is the - one part of the AV path that a real relay can contradict; it is worth - re-checking against whichever relay is targeted before the UI lands. - Likewise `livekit_supported` treats "not `204`" as "no AV" rather than an - error, because it only feeds a button. -10. **`Monitor`.** Not adopted. It is only reachable through - `ClientBuilder::monitor(Monitor::new(n))`, so nothing can read per-relay - status until `crates/state` opts in, and its `StatusChanged` stream does not - distinguish a permanent outage from a reconnect — so it would need filtering - before it could mean anything. The only thing it buys here is running the - admins' `10009` check the instant a group's relay drops, rather than at the - next 15-minute tick. The NIP frames that as MUST, but the substance is - already covered: the admins come from the cached `39001`, so the check runs - with the relay gone, just later. If that latency ever matters, the hook is - `sync_forks(true, cx)` driven by a `StatusChanged` consumer — one place. -- 2.54.0 From 99124eaf1be6f7e01d9ada5edd5161779c163c36 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 30 Sep 2026 13:35:59 +0700 Subject: [PATCH 10/27] add nip29 document --- docs/nip29-usage.md | 572 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 572 insertions(+) create mode 100644 docs/nip29-usage.md diff --git a/docs/nip29-usage.md b/docs/nip29-usage.md new file mode 100644 index 00000000..7eace0a1 --- /dev/null +++ b/docs/nip29-usage.md @@ -0,0 +1,572 @@ +# Using the groups backend + +`crates/nip29` is the backend for [NIP-29](https://github.com/nostr-protocol/nips/blob/master/29.md) +relay-based groups. It is a GPUI crate: it owns one registry entity and one entity +per group, subscribes to the relays, caches everything locally and hands the UI +`chat::Message`s. It owns no strings a user reads — the UI layer decides every +rendering. This document is the map from a UI action to the calls it makes. + +A group is a `(relay, id)` pair. The relay enforces membership and signs the +group's state; there is no cross-relay identity, no encryption, and no portable +"create". The same `id` on two relays is two groups (a fork), which the backend +models as two entities. + +## Modules + +| File | Owns | +| --- | --- | +| `src/protocol.rs` | Pure NIP-29 value types and their `impl` blocks: `GroupId`, `GroupKey`, `GroupReference`, `GroupMetadata`, `GroupAdmins`/`GroupMembers`/`GroupRoles`/`GroupParticipants`, `Pin`/`GroupPins`, `LivekitSession`, `GroupList`/`GroupListEntry`, `Membership`, `TimelineWindow`, plus the filter and event builders on `GroupId`. No I/O. | +| `src/group.rs` | The `Group` entity: one group's state, timeline, membership, writes, the envelope cache helpers and the two LiveKit calls. `GroupEvent`. | +| `src/lib.rs` | The `GroupsRegistry` global, `init`, the notification pump, the account's `10009` list, the fork watch and relay browsing. `GroupsEvent`. | + +`protocol` is re-exported at the crate root (`pub use protocol::*`), so callers +write `nip29::GroupKey`, `nip29::GroupMetadata`. `group` is re-exported the same +way (`Group`, `GroupEvent`). + +The crate's constants are the protocol's: `RENDER_KINDS` (`1`, `9`, `11`, `1111`, +`7`), `STATE_KINDS` (`39000`–`39004` and `39005`), `MEMBERSHIP_KINDS` (`9000`, +`9001`), `PIN_LIST` (`39005`), `UPDATE_PIN_LIST` (`9010`), `TIMELINE_WINDOW` +(50) and the `previous`-tag shape (3 refs, 8 hex chars). + +## Turning it on + +One line, after the other registries and gated on nothing — the registry does its +own sign-in check: + +```rust +nip29::init(cx); +``` + +It builds the global entity, subscribes to `NostrRegistry`, and on the first +sign-in (or a signer change) resets its state, restarts the pump and loads the +account's `10009`. No groups exist before a sign-in, and everything is torn down +when the signer changes, so the crate never leaks one account's groups into +another. + +`nip29::init(cx)` is already called in `desktop/src/main.rs` and `web/src/lib.rs`. + +```rust +use nip29::GroupsRegistry; + +let groups = GroupsRegistry::global(cx); // Entity +groups.read(cx).groups(); // &[Entity] +groups.read(cx).group(&key, cx); // Option> +``` + +`GroupsEvent` is emitted on the registry; read it back on the event. The sidebar +subscribes to `GroupsEvent` and renders `groups()` — pending and refused groups +are in that list alongside members, and their `Group::membership()` says which. + +```rust +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GroupsEvent { + Updated, + Error(String), +} +``` + +`Updated` fires on any change to `groups()` or to a browse result; `Error` is only +emitted when publishing a `9007` (create) is refused, and carries the relay's +message. + +## Value types + +```rust +use nip29::{GroupId, GroupKey, GroupReference, GroupMetadata}; + +let id = GroupId::new("pizza-lovers")?; // trimmed, non-empty +let key = GroupKey::new(relay_url.clone(), id); // the (relay, id) pair +``` + +A `GroupKey` is the identity of a group *as this backend sees it*: everything +that addresses a group — `group`, `join_key`, `create`, `load_more`, `leave`, +`browse` results — takes one. `Group::key()` returns the one an entity was built +with, and `GroupReference::key()` returns the one a link resolves to. + +A reference is a link, and only a link: + +```rust +let reference = GroupReference::parse("naddr1…?invite=CODE")?; // or .parse::() +``` + +`GroupReference::parse` (and `FromStr`) accepts only a kind-`39000` `naddr` with a +relay hint. The `?invite=` suffix the NIP defines is split off before bech32 +decoding and lives on `reference.invite()`; a missing relay hint is an error, +because there is nowhere to send the join request. `id@relay` is deliberately not +accepted. + +`GroupMetadata` is both the read model and the `9002`/`9007` payload, and it is +`Default`: + +```rust +let metadata = GroupMetadata { + name: Some("Pizza Lovers".into()), + private: true, + ..Default::default() +}; +``` + +`parse(event)` fills it from a relay-signed `39000` (and keeps the event's id and +author privately, which is what `naddr` is built from). `name()`, `supports(kind)` +and the public flag fields (`private`, `restricted`, `hidden`, `closed`, +`livekit`, `parent`, `children`, `supported_kinds`) are what a group header reads. +`supports` distinguishes "tag absent" (all kinds) from "tag present with no +values" (no kinds) — that is the difference between a normal group and an AV-only +one. + +## The account's group list + +`10009` ("simple groups") is the member's own list of memberships, synced across +devices. The registry keeps it in `list: GroupList` and owns its whole lifecycle: +it is loaded at sign-in, `upsert`ed on join/create, `remove`d on leave, and +re-published to `to_nip65()` after every change. A caller never touches it. + +```rust +// `10009` tag shape: ["group", id, relay, name?] plus ["r", relay] +``` + +Loading is a subscription plus a database read, the same read model as everything +else: subscribe to our own `10009` under `nip29-list`, then read the newest from +`client.database()` and merge it. A newer list arriving from another device is +merged union-only — the backend never diffs, so a removal another client made is +not detected, and the local state is never walked backwards. `GroupList::parse` +requires `["group", id, relay]` and takes the third value as the display name. + +## Joining + +```rust +GroupsRegistry::global(cx).update(cx, |registry, cx| { + registry.join(reference, cx); // or join_key(key, invite, cx) +}); +``` + +`join` is `join_key(reference.key(), reference.invite().to_owned())`, and +`join_key` is what every other path calls too, because a browse result has a key +and no `naddr`. The sequence is: + +1. `ensure` the group: create the entity, register it in `groups()`, subscribe to + its relay (the standing `grp-` REQ, below) and reload it from the local + cache. +2. Add the entry to `10009` optimistically and publish the list, so the group is + listed the moment the user asks for it. +3. Publish `key.id().join_request(invite, "")` — the `h` tag plus, when the link + had one, a `code` tag. `Group::mark_pending(now)` runs immediately, so + `Group::membership()` is `Pending { since }` while the relay decides. + +The relay either emits `9000` (the standing subscription folds it into +`Membership::Member`), or answers the `9021` with `OK false` — the registry +catches that and sets `Membership::Refused { reason }` from the relay's message. +The NIP promises that message explains whether the rejection is final, pending +review or payment-required, so show it verbatim and offer a retry. A join to a +`closed` group is refused the same way, unless the invite code is honoured. + +Join needs no `39000` first: nothing is fetched, the `9021` is the whole request. +A hidden group needs no special handling either — the state simply never arrives. + +## Reading a group + +`Group` is an entity with no protocol state in the view. A panel reads it and +subscribes to `GroupEvent`: + +```rust +use nip29::{Group, GroupEvent}; + +let Some(group) = registry.read(cx).group(&key, cx) else { + return; +}; +let messages = group.read(cx).messages(); // &[chat::Message] +``` + +```rust +cx.subscribe(&group, |this, group, event, cx| match event { + GroupEvent::Incoming(..) | GroupEvent::Updated => cx.notify(), + GroupEvent::Refused(reason) => this.show_error(reason, cx), + GroupEvent::Eose => this.stop_spinner(cx), + GroupEvent::Sent(_) => {} +}) +``` + +`GroupEvent` is the full surface: + +| Variant | Means | +| --- | --- | +| `Incoming(Event)` | a fresh renderable message was folded in (use it for system notifications when the window is inactive) | +| `Sent(Event)` | our own event was accepted by the relay; the registry re-reads the group, the panel normally ignores it | +| `Updated` | metadata, state, membership, pins or a finished page changed | +| `Eose` | the standing subscription settled | +| `Refused(String)` | the relay `CLOSED` the standing subscription, with its message | + +The reads are all accessors: `key`, `metadata`, `members`, `admins`, `roles`, +`participants`, `pins`, `membership`, `elsewhere`, `messages`, `loading`, +`loading_more`, `oldest`, `refused`. + +### The read model + +There is **no `fetch_events` anywhere** in the crate. A group is read by +subscribing, then querying the local database — the same model as `crates/chat` +and `crates/community`: + +``` +subscribe grp- the global pump observes every REQ on the client + │ (timeline + state + membership filters, one relay) + ▼ +save_envelope 30078, d = event id, r = "|", k = kind + │ content = the raw event JSON, signed by crate-local keys + ▼ +load_envelopes query by the r tag, parse, sort by created_at + │ + ▼ +Group::hydrate the only place entity state changes +``` + +The standing REQ carries three filters under one id: the timeline +(`GroupId::timeline_filter(100)`), the relay state (`state_filter`) and our own +membership (`membership_filter(me)`). Its id is `grp-` plus 8 hex of a hash of the +key — NIP-01 caps ids at 64 characters, and the pump needs a prefix to recognise +its own subscriptions, so the real key is held in the registry's `routes`, not +encoded in the id. + +The envelope cache is what makes the timeline fork-safe and restart-safe without +teaching the nostr database about relays (a stored event carries no origin): the +`r` tag *is* the relay scoping. It is also why every event is re-read instead of +consumed off the wire — the pipeline dedups, sorts and survives a restart, and a +page, a live tail and a local send all take the same path. + +`hydrate` partitions an envelope run: + +- `STATE_KINDS` → `metadata`, `admins`, `members`, `roles`, `participants`, + `pins` (relay-signed, read-only, addressable by `d`). +- `MEMBERSHIP_KINDS` → our membership only (see below). +- `RENDER_KINDS` → a `chat::Message`, inserted in `created_at` order. +- everything else is kept only for the `previous` window. + +Every `h`-tagged event feeds the window regardless of kind. The window is the +NIP's timeline reference: the last 50 events seen on that relay, excluding our +own, first 8 hex characters of their ids, up to 3 per send. The write path +attaches it automatically via `Group::previous_tag(me)`, so callers never build +`previous` themselves. + +`Group::loading()` is true until EOSE; `Group::refused()` carries the relay's +`CLOSED` message when it never settles. A relay may answer a restricted or +private group with `restricted:` / `auth-required:` — the state stays, the group +just reads empty, and that is not an error toast. + +### Older messages + +```rust +GroupsRegistry::global(cx).update(cx, |registry, cx| { + registry.load_more(&key, cx); +}); +``` + +It is a no-op while a page is in flight (`Group::loading_more()`) or before +anything is cached. Otherwise it sends a `pg-` REQ (`timeline_page(50, +until)`) alongside the standing one — paging must not replace the live tail — and +the pump caches each page event and reloads the group exactly like a live event. +The cursor is `Group::oldest()`, the oldest `h`-tagged event ever hydrated; `until` +is inclusive, so a page repeats its first event, which the dedup absorbs for free. +EOSE, a page `CLOSED` or the 10s auto-close all clear the spinner. + +One relay behaviour is worth a spinner that can be cancelled: a relay that accepts +the REQ but never EOSEs leaves `loading_more` true, because the auto-close closes +the subscription locally with no notification at all. + +## Sending + +Every write goes through one path — `Group::dispatch` builds the event with +`key.id()` and the current `previous` tag, `Group::publish` adds and connects the +relay, signs with the app signer and sends to that one relay: + +```rust +let sent = group.update(cx, |group, cx| group.send_message(&text, replies, cx)); +cx.spawn(async move |_, _| { + if let Err(error) = sent.await { + // the relay's OK-false message, verbatim: "invalid: ...", "restricted: ...", "duplicate: ..." + } +}); +``` + +| Call | Wire | +| --- | --- | +| `send_message(content, replies: Vec, cx)` | kind `9` + `h` + `e` per reply | +| `send_reaction(target: EventId, emoji: String, cx)` | kind `7` + `h` + `e` | +| `put_user(public_key, roles, cx)` | `9000` | +| `remove_user(public_key, cx)` | `9001` | +| `edit_metadata(&GroupMetadata, cx)` | `9002` | +| `delete_event(EventId, cx)` | `9005` | +| `create_invite(code, cx)` | `9009` | +| `update_pins(Vec, cx)` | `9010` | + +Each returns `Task>` and each resolves only once the relay has +answered: `OK true` emits `GroupEvent::Sent` and the registry reloads the event +through the normal cache path, so the timeline shows a send without waiting for +the live tail; `OK false` is an `Err` carrying the relay's message. There is no +offline queue — NIP-29 relays reject late publication, so a send either goes now +or fails visibly. + +Kind 9 is the default message kind. When `metadata().supports(Kind::ChatMessage)` +is false — an AV-only group, or one whose `supported_kinds` omits it — disable the +composer instead of sending a kind the relay does not take. + +`replies` are plain `EventId`s; they become `e` tags. There is no thread model in +the backend beyond that: `chat::Message::replies_to` already reads them back. + +## Membership + +Membership is derived from the relay's own events, never guessed: + +```rust +pub enum Membership { + Unknown, + Pending { since: Timestamp }, + Member, + Refused { reason: Option }, + Removed, +} +``` + +`Membership::from_events(&[...], me)` takes the newest `9000`/`9001` whose `p` +tags name us — on an exact timestamp tie it prefers `9001` — and `Unknown` when +there is none, which the NIP says means "not a member". The `Group` keeps only +the membership events that mention us, so `refresh_membership` costs nothing. + +`Pending` and `Refused` are the two local states, because the relay does not fold +them into an event: the join path sets them from the `9021` send result. A +`Member` is never overwritten by `mark_pending` or `mark_refused`, so a stale +refusal cannot demote a member. + +Count a member who is `Unknown`, `Pending` or `Refused` as "not in the room" for +writes, but keep the row visible: pending and refused groups stay in `groups()` +with their state on the row. + +## Moderation + +The `9000`–`9010` writes are on `Group` and share `dispatch`, so they are the +same shape as sending a message. The backend does not enforce authority — the +relay owns roles and its own policy — it only surfaces the refusal. Gate the UI +on the caller appearing in `Group::admins()` (each entry is a pubkey plus its role +labels), read the relay's supported roles from `Group::roles()`, and let the relay +reject anything it does not want. + +`edit_metadata` takes a `GroupMetadata`, so a form can start from +`Group::metadata().cloned().unwrap_or_default()`, mutate, and send. `children` +must carry every child tag, because a `9002` that drops one is rejected by the +relay; the builder re-emits them from the struct. + +`delete_event` is a **relay** instruction only. The relay stops serving the event, +but the local timeline keeps it: the backend does not evict the envelope or the +message, so a deletion the relay accepted is still visible locally until the UI +phase drops it. That is the one moderation write left incomplete (see below). + +## Pins + +```rust +use nip29::Pin; + +let pins: Vec = vec![Pin::Event(event_id), Pin::Address(coordinate)]; +group.update(cx, |group, cx| group.update_pins(pins, cx)); +``` + +`9010` carries the whole list, so pinning, unpinning, reordering and clearing are +all one call. The relay mirrors it into `39005`, which is in `STATE_KINDS` — so +`Group::pins()` reflects the accepted list on the next reload. + +## Subgroups + +`GroupMetadata` carries `parent: Option` and `children: Vec`, +and the metadata builder re-emits both, so a create or edit can declare the link. +There is no tree helper: the tree is scoped to one relay, so assemble it from +`Group::metadata()` across `groups()` where all the children have the same relay. +Membership and admin roles do **not** cascade across the link — each group's +`9000`/`9001` and `39001` are authoritative for its own scope — so never infer +access from a parent. + +## LiveKit + +Two calls, both off the foreground thread and both `&App`: + +```rust +if group.read(cx).metadata().is_some_and(|m| m.livekit) { + let available = group.read(cx).livekit_supported(cx).await?; // GET /.well-known/nip29/livekit + let session = group.read(cx).livekit_session(cx).await?; // LivekitSession { token, server_url } +} +``` + +`livekit_supported` is an unauthenticated GET that answers `true` only for `204`. +`livekit_session` signs a NIP-98 `Authorization` header (`27235`, with the `u` and +`method` tags the SDK's verifier demands) and returns the JWT and server URL; the +token is a per-join artifact, returned not stored. Both requests carry an explicit +`RedirectPolicy::NoFollow` and a 10s timeout, because the header is signed for +exactly one URL and GPUI applies no timeout unless asked. The backend stops at the +JWT — no LiveKit client library is involved — so the AV room itself is UI work. + +Check `metadata().livekit` before offering the button, and read +`Group::participants()` (kind `39004`, `participant` tags, relay-published) for +who is live. Absent and empty are the same value there: nobody is in the room. + +## Forks and migration + +A `10009` on the *admins'* relays says where each of them thinks the group is. The +registry keeps one standing `nip29-forks` subscription over +`{authors: every admin of every group, kinds: [10009]}` on `ReqTarget::auto` — +which is why it still works when the group's own relay is down — folds each event +into a per-author list, and recomputes every group's `elsewhere`. + +```rust +if let Some(moved) = group.read(cx).elsewhere() { + // the group may have moved or been forked; offer: + registry.update(cx, |registry, cx| registry.join_key(moved.clone(), None, cx)); +} +``` + +`elsewhere()` is `Some(key)` when an admin of the group lists the same group id at +a different relay. The data cannot distinguish a move from a fork, hence the +neutral name; nothing is auto-joined, because the NIP asks the client to *offer*. +Evaluation is incremental on each event rather than batched on EOSE, since +`ReqTarget::auto` fans out and an early EOSE would evaluate against one relay's +partial view. A 15-minute background tick re-subscribes regardless, which is the +recovery path for a subscription that died or a client that was offline. + +`Monitor` is not adopted: it is only reachable through `ClientBuilder::monitor`, +so nothing can read per-relay status without changing `crates/state`. The fork +check does not need it — the admin pubkeys come from the cached `39001`, so the +check runs with the relay gone, just at the next tick. + +## Browsing a relay + +```rust +use nip29::Browse; + +registry.update(cx, |registry, cx| registry.browse(relay_url.clone(), cx)); +let browse: Option<&Browse> = registry.read(cx).browsed(&relay_url); +``` + +`Browse` is `{ relay, loading, groups: Vec }`, where a candidate +is a `GroupKey` plus its parsed `GroupMetadata`. `browse` connects the relay, +sends one auto-closing `39000` REQ (limit 500, EOSE or 10s) and returns +immediately; the pump folds each relay-signed metadata event into the result. Read +it back in a `GroupsEvent::Updated` handler, which also fires when the browse +settles (`loading` flips false on EOSE or a refusal). Join a result with +`join_key(candidate.key, None, cx)`. + +One caveat: `Browse::loading` can stick if the relay accepts the REQ and never +EOSEs, because the auto-close timeout closes the subscription locally with no +notification. Treat it as "still trying", not as an error. + +## Creating a group + +```rust +let metadata = GroupMetadata { name: Some(name), ..Default::default() }; +registry.update(cx, |registry, cx| registry.create(key, metadata, cx)); +``` + +Creation mirrors join: `ensure` the entity, add it to `10009` and publish optimistically, +then send the `9007`. There is no NIP-11 check to lean on, so "where the relay +supports it" is enforced by the relay refusing the event — on refusal the registry +forgets the group and emits `GroupsEvent::Error(reason)`. On success the relay +republishes `39000`, which the standing subscription picks up like any other state. + +## Leaving + +```rust +registry.update(cx, |registry, cx| registry.leave(&key, cx)); +``` + +`leave` sends `9022`; once the relay accepts, the registry forgets the group: +unsubscribe both REQs, drop the entity and its observer, remove the `10009` entry, +delete every cached envelope for that group's `r` tag, and publish the shortened +list. A failed leave leaves everything untouched, so the group stays. There is no +separate local removal — a group someone else removed us from stays listed until +we leave it ourselves. + +## GPUI integration + +`init` builds the global; the registry and its groups are the only entities, and +`protocol.rs` has no GPUI dependency at all. + +```rust +pub fn init(cx: &mut App) { + GroupsRegistry::set_global(cx.new(GroupsRegistry::new), cx); +} + +impl GroupsRegistry { + pub fn global(cx: &App) -> Entity { + cx.global::().0.clone() + } +} +``` + +- `GroupsRegistry` holds `groups: Vec>`, the `10009` `list`, the + live `browse` results, `routes` (subscription id → `GroupKey`), one + `Subscription` per group, the fork state, and its tasks. +- `Group` owns everything the panel draws: metadata, admins/members/roles, + participants, pins, membership, the `Vec` timeline, the `previous` + window, `loading`/`loading_more`/`refused`. +- Views render `Entity` and re-read on `GroupEvent`; no protocol state + lives in a view. + +### Foreground and background + +One background task drains `client.notifications()` and never touches an entity; +it sends signals through an unbounded `flume` channel that one foreground +`cx.spawn` consumes, which is the only place entities change: + +``` +notifications() ──sig──► flume ──recv──► this.update(cx, …) ──► handle_event → reload → hydrate +``` + +- `handle_notifications` runs on `cx.background_spawn`; it matches + `ClientNotification::Message` and routes the message by subscription id. +- The consumer runs on `cx.spawn`; every arm is one `this.update(cx, ..)`. +- The listener and the consumer are replaced (`= None` first) on a signer change, + because a dropped `Task` cancels; that is what makes the reset clean. +- Both `save_envelope` and `load_envelopes` are `cx.background_spawn` work that + clones the `Client` in, so no database call ever runs on the foreground thread. +- NIP-46 signing is async, so all signing happens inside the background task. + +### Subscriptions and routing + +One `Client` and one notification stream are shared with `chat` and `community`, +so every subscription the crate owns is identified by a prefix and nothing else: + +| Id | Meaning | +| --- | --- | +| `nip29-list` | the account's `10009` | +| `nip29-forks` | the admins' `10009` | +| `grp-` | a group's standing REQ (timeline + state + membership) | +| `pg-` | a "load more" page | +| `brw-` | a relay browse | + +Inbound events are routed by `subscription_id` from `RelayMessage::Event`, never +by kind. That matters because `crates/chat` treats every kind-1059 event as a +NIP-59 gift wrap — anything a new subscription adds must be recognisable by id, and +the pump drops ids it did not mint. Duplicate event ids are filtered in the pump +before they reach the consumer, so a replay cannot double-hydrate. + +## Not wired up yet + +- **No screen consumes this crate yet.** The only references outside + `crates/nip29` are the two `nip29::init(cx)` calls, so the sidebar section, the + group panel and the join dialog are still to be built. The panel needs + `chat::Message` rendering lifted (or copied) out of `ChatPanel`: the data type + is already shared, only the row rendering is not. `crates/workspace/src/panels/browse.rs` + is the stub for browsing. +- **`9005` delete does not touch local state.** The relay stops serving the + event, but the envelope and the row stay until a reload. Dropping it needs a + filter on the target's `d` tag plus the group's `r` cache tag, and an eviction + from `messages` and the id index; nothing does that today. +- **The LiveKit response shape is a guess.** The NIP names no fields, so + `LivekitSession::parse` reads `token` (or `jwt`) and `url` (or `server_url`) and + fails loudly on anything else. Re-check it against the relay actually targeted + before building the AV UI, or the join button fails silently. +- **A relay that never EOSEs sticks a spinner.** `Browse::loading` and + `Group::loading_more` both rely on EOSE or CLOSED, and the auto-close timeout + closes the subscription with no notification at all. Bind "load more" to + `Group::loading_more()` and give the user a way out. +- **Subgroups are parsed, not assembled.** `parent`/`children` are on + `GroupMetadata` and the writes carry them; there is no tree builder, because + the tree is scoped to a relay and that is a view concern. +- **`GroupMetadata::naddr` needs a parsed metadata.** It builds from the `id` and + author the `39000` left on the struct, so a hand-built `Default` value errors + rather than minting a link that points nowhere. A group whose relay publishes + no `39000` cannot be referenced or joined at all. -- 2.54.0 From cb10c2d26eaedf3ff30f2cf4325ad41b364d0fbb Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 30 Sep 2026 14:06:19 +0700 Subject: [PATCH 11/27] add migration plan --- docs/nip29-migration-plan.md | 373 +++++++++++++++++++++++++++++++++++ 1 file changed, 373 insertions(+) create mode 100644 docs/nip29-migration-plan.md diff --git a/docs/nip29-migration-plan.md b/docs/nip29-migration-plan.md new file mode 100644 index 00000000..7336e2f1 --- /dev/null +++ b/docs/nip29-migration-plan.md @@ -0,0 +1,373 @@ +# NIP-29 migration plan + +`crates/nip29` is complete and reviewed; `docs/nip29-usage.md` is its API contract. This +plan replaces the Concord stack — `crates/concord`, `crates/community` and +`crates/community_ui` — with it, and lays out the UI surfaces the backend was built for. + +Every phase ends with the app building and running. The last phase deletes the old crates, +so nothing is removed until the replacement is in place. + +## What is being replaced + +| Today | Replacement | +| --- | --- | +| `crates/concord` (`cord01`–`cord06`, `state`, `derive`) | deleted — the relay owns identity, membership, roles and history | +| `crates/community` (`CommunityRegistry`, `Community`, `sync`, `cache`, `rekey`, `history`) | `crates/nip29` (`GroupsRegistry`, `Group`) — already built | +| `crates/community_ui` (`CommunityPanel`) | new `crates/nip29_ui` (`GroupPanel`) | +| Sidebar `Communities` tab + two-level community view (channels / admins / members) | one-level `Groups` tab; admins and members move into a details dialog | +| `dialogs/new_community.rs` | `dialogs/new_group.rs` and a new `dialogs/join_group.rs` | +| `panels/browse.rs` stub | a working browse panel over `GroupsRegistry::browse` | +| `settings.recent_communities` (dead, nothing reads it) | removed | + +## Capability disposition + +| Concord capability | On NIP-29 | +| --- | --- | +| End-to-end encrypted channels | Dropped. NIP-29 groups are relay-readable; `private` only means members-only read. | +| Channels inside a community | Dropped. A group is one timeline; subgroups are the only hierarchy. | +| Roles and control plane | Relay owns them (`39001`, `39003`); the client reads and displays, never derives authority. | +| Rekey, rotation, refounding, dissolution | Dropped with `cord06`; leaving and deleting are relay operations. | +| Invites | `9009` `create-invite` + the `?invite=` suffix on a group `naddr`. | +| Pins | `9010` → relay mirrors `39005`; the backend is ready. | +| Message deletion | `9005`; the local eviction lands in Phase 0. | +| Reactions | Kind `7` rows arrive as ordinary timeline rows; grouping them under the target is a later enhancement. | +| Members list | `39002` when the relay publishes it; may be absent or partial by design. | +| Community list | `10009`, owned entirely by the backend. | +| Multi-relay communities | One relay per group; migration and forks surface through `Group::elsewhere()`. | +| Attachments | Any kind the relay supports; v1 renders media URLs already parsed by `chat::Message`. | + +## Rules the implementation keeps + +Carried over from the backend review; they apply to every phase. + +- New code goes into `crates/nip29` and `crates/nip29_ui`. Never create a `groups` crate. +- Implementation-block style: methods on the type they belong to, `self` first. +- Read model is subscribe → pump → envelope cache → hydrate. Never `fetch_events`. +- Short-lived subscriptions close on EOSE (`SubscribeAutoCloseOptions`), long-lived ones use a + prefix the pump recognises; dedup events by id before they mutate state. +- I/O runs on `cx.background_spawn`, called from a foreground `cx.spawn` task; tasks are + stored on the entity so they cancel with it. +- No comments, no docs, no tests inside the crates. `docs/` is the only place for prose. +- No premature optimization: scan the small vectors, don't build index maps. +- No `unwrap`, no `let _ =` on fallible calls; propagate with `?` and surface errors to the UI + (relay messages verbatim). +- Views read entities; protocol state never lives in a view. A panel re-reads on the event. +- UI follows the existing components (`ui::button`, `ui::input`, `ui::dock`, `ui::message`, + `ui::markdown`, `theme` tokens). Copy is sentence case; a dialog names its object + (`Leave "Pizza Lovers"?` with a `Leave` button). +- `Monitor` stays unadopted; the fork watch already works without relay status. + +--- + +## Phase 0 — `9005` deletes reach local state + +Today a `9005` stops the relay serving the event, but the envelope cache and the timeline keep +it. Everything else about deletion is done (the write, the `OK false` path). + +Files: `crates/nip29/src/protocol.rs`, `crates/nip29/src/group.rs`, `crates/nip29/src/lib.rs`. + +1. `TimelineWindow::remove(&mut self, id: EventId)` — drop the reference so a `previous` tag + never points at a deleted event. +2. `Group::forget(&mut self, id: EventId, cx: &mut Context)` (crate-private) — + `messages.retain`, `index.remove`, `window.remove`; when something was removed, emit + `GroupEvent::Updated` and `cx.notify()`. `hydrate` stays additive, so a later reload cannot + resurrect the row once its envelope is gone. +3. The registry's ingestion of our accepted delete: in `ensure`'s `GroupEvent::Sent` arm, when + `event.kind == Kind::Custom(9005)`, take the target from `event.tags.event_ids().next()`, + delete its envelope (`Kind::ApplicationSpecificData` + `Tag::identifier(target)` + + `r = key.cache_tag()`) on the background executor, wait for that to finish, then + `group.forget(target)` and the usual `reload(key, Some(event))`. The deletion must complete + before the reload reads the envelopes, or the reload would see the target still cached. + +Remote deletes (another admin's `9005` arriving live) are out of scope; the standing REQ does +not carry them, and a relay that no longer serves the event cannot be asked for it. + +Validation: `cargo clippy -p nip29 --all-targets`, `cargo fmt -p nip29`, +`cargo check -p coop -p coop_web`. Manually: as an admin, delete a message and confirm the row +disappears and stays gone after a restart. + +--- + +## Phase 1 — `crates/nip29_ui`: the group panel + +A new crate, modeled on `community_ui`, that renders one `Entity`. + +Files: `crates/nip29_ui/Cargo.toml`, `crates/nip29_ui/src/lib.rs`, +`crates/nip29_ui/src/message.rs`; one line in `crates/chat_ui/src/lib.rs`. + +```toml +# Cargo.toml +[dependencies] +nip29 = { path = "../nip29" } +chat = { path = "../chat" } +chat_ui = { path = "../chat_ui" } +state = { path = "../state" } +ui = { path = "../ui" } +theme = { path = "../theme" } +common = { path = "../common" } +person = { path = "../person" } +settings = { path = "../settings" } + +gpui.workspace = true +nostr-sdk.workspace = true +anyhow.workspace = true +smallvec.workspace = true +``` + +Make `chat_ui`'s text helper shared by flipping `mod text;` to `pub mod text;`. It is the only +change to an existing UI crate in this phase. + +```rust +pub fn init(group: Entity, window: &mut Window, cx: &mut App) -> Entity; + +pub struct GroupPanel { + id: SharedString, // "group-" + focus_handle: FocusHandle, + group: WeakEntity, + list_state: ListState, + rendered_texts_by_id: BTreeMap, + input: Entity, + tasks: SmallVec<[Task>; 4]>, + _subscriptions: SmallVec<[Subscription; 2]>, +} +``` + +The panel keeps **no copy of the timeline**. Rows are `group.read(cx).messages()`; the only +local cache is parsed markdown keyed by event id. Deleting a message, a page arriving, or a +live tail all reduce to "the entity changed, re-read it" — no merge, no index. + +Rows: `0` welcome, `1` load-older, then the messages. + +- Welcome — `WelcomeMessage` with the group avatar; `Welcome to {name}` and + `This is the start of {name}.` +- Load older — visible when `group.oldest().is_some()`, disabled while + `group.loading_more()`, click → `GroupsRegistry::global(cx).update(|registry, cx| registry.load_more(&key, cx))`. +- Message — `MessageRow` with avatar, author (`PersonRegistry`), `created_at.to_human_time()`, + and `chat_ui::text::rendered_text(&message.content, &message.mentions, &persons, true, cx)` + cached in `rendered_texts_by_id`. Show the author on a run start (same author, gap under a + few minutes) like `community_ui` does. Render `message.media` as chat does: one image + full-width, several in a wrapped row. + +Events: subscribe to the group and drive everything from it. + +| `GroupEvent` | Panel | +| --- | --- | +| `Incoming` / `Updated` | sync `list_state` (`reset(item_count)` when the count changed) and `cx.notify()` | +| `Eose` | `cx.notify()` — `group.loading()` is now false | +| `Refused(reason)` | show the notice | +| `Sent` | ignore; the registry reloads through the same cache path | + +Composer, gated exactly on the NIP's flags: + +```rust +let metadata = group.metadata(); +let supports_text = metadata.is_none_or(|m| m.supports(Kind::ChatMessage)); +let writable = metadata.is_none_or(|m| !m.restricted) || matches!(group.membership(), Membership::Member); +let can_send = supports_text && writable; +``` + +Send mirrors `community_ui::send`: read `input.value()`, clear the textarea, spawn +`group.send_message(&text, vec![], cx)` and turn an `Err` into an +`autohide(false)` notification — that string is the relay's refusal, verbatim. The panel does +not reload anything; `GroupEvent::Sent` already routes the accepted event through the registry. + +Notice above the composer (and centered when there are no messages): + +| State | Notice | Action | +| --- | --- | --- | +| `group.refused()` | Relay message | none | +| not a member (`Unknown` / `Removed` / `Refused { reason }`) | `Request to join`, preceded by the refusal reason when there is one | `registry.join_key(key, None, cx)` | +| any of the above while `metadata.closed` | `This group is closed; an invite link is required.` | disabled | +| `Membership::Pending { .. }` | `Waiting for the relay to approve.` | disabled | +| `!supports_text` | `This group doesn't support text messages.` | disabled | + +`Panel` impl: `panel_id` is `group-{cache_tag}` so a group opens once; `title` is the avatar +plus the name; `toolbar_buttons` starts empty (Phase 4 fills it). + +Validation: `cargo clippy -p nip29_ui --all-targets`, `cargo fmt -p nip29_ui`, +`cargo check -p coop -p coop_web`. The crate is not reachable from the UI until Phase 2, so +review is by code and compile. + +--- + +## Phase 2 — the sidebar and its dialogs + +The `Communities` tab becomes one level: a list of groups, a `New Group` dialog and a +`Join Group` dialog. The two-level community view (channels, admins, members, the back +button) is removed; with no channels it has nothing to show, and details move to Phase 4's +dialog. + +Files: `crates/workspace/src/sidebar/mod.rs`, `crates/workspace/src/sidebar/tab.rs`, +`crates/workspace/src/lib.rs`, `crates/workspace/src/dialogs/mod.rs`, +`crates/workspace/src/dialogs/new_group.rs` (replaces `new_community.rs`), +`crates/workspace/src/dialogs/join_group.rs`, `crates/workspace/Cargo.toml`. + +Add `nip29` and `nip29_ui` to the workspace crate. + +`tab.rs`: `Communities` → `Groups`, label and list title `Groups`, list id `sidebar-groups`. +`lib.rs`: `Command::NewCommunity` → `Command::NewGroup`; add `Command::JoinGroup`; both arms +open the dialogs below. + +`mod.rs`: + +- `SidebarRow::Community { community }` → `SidebarRow::Group { group: Entity }`. +- Delete `CommunitySection`, `CommunityRow`, `community_scroll`, `channels_open`, + `admins_open`, `members_open`, `Sidebar::community`, `reset_community`, + `render_community`, `render_community_rows`, `section_row`, `channel_row`, `member_row`, + and the back button in `render_user`. `render` loses its community branch. +- `rows_for(Groups)` reads `GroupsRegistry::global(cx).read(cx).groups()` in registry order + (the `10009` order, then joins). +- The group row: name from `metadata.name()`, falling back to `key.id().as_str()`; avatar from + `metadata.picture`, seeded with `key.cache_tag()`; suffix for the membership state — + `Requested` (`Pending`), `Refused`, `Removed`, and `Moved` when `elsewhere().is_some()`; + nothing for a plain member. Click opens + `PanelHandle::new(nip29_ui::init(group.clone(), window, cx))` in `DockPlacement::Center`. +- Subscriptions: replace the `CommunityRegistry` subscription with `GroupsRegistry` — + `GroupsEvent::Updated` → `cx.notify()`, `GroupsEvent::Error(error)` → notification. +- `render_tabs`'s Groups section: `New Group` (`Command::NewGroup`), `Join Group` + (`Command::JoinGroup`), `Browse` (`Command::ShowBrowse`, still the Phase 3 stub). +- Remove `dialogs/new_community.rs` and its `pub mod` line — nothing dispatches it any more. + +The two dialogs, both modeled on `new_community.rs`: + +**New group.** Three inputs — name, group id, relay — with ok/validation: + +```rust +let id = GroupId::new(if raw_id.is_empty() { slug(&name) } else { raw_id })?; +let relay = RelayUrl::parse(relay_input)?; +registry.create(GroupKey::new(relay, id), GroupMetadata { name: Some(name), ..Default::default() }, cx); +``` + +A parse failure keeps the dialog open and pushes a notification. The relay's answer arrives +asynchronously as `GroupsEvent::Error(reason)` — the sidebar already shows it. There is no +default relay; the account has no "groups relay" setting yet, so the field starts empty. + +**Join.** One naddr input: + +```rust +let reference = GroupReference::parse(input)?; // accepts naddr1…?invite=CODE +GroupsRegistry::global(cx).update(cx, |registry, cx| registry.join(reference, cx)); +``` + +Validation: `cargo check -p workspace`, `cargo clippy -p workspace --all-targets`, +`cargo fmt -p workspace`, then run the app: the tab lists whatever the account's `10009` +holds, no Concord screen is reachable from it, and a pasted naddr joins and opens. + +--- + +## Phase 3 — browse panel + +`panels/browse.rs` stops being a stub. `init` takes the dock (`WeakEntity`) the way +`search::init` does, so a joined group can be opened directly. + +- Header: relay `Input` + a `Go` button; Enter submits. `RelayUrl::parse` → `registry.browse(relay, cx)`. +- Body: `registry.browsed(&relay)` — a spinner while `Browse::loading`, then rows of + `GroupCandidate`: avatar, name (or id), `id@relay`, and `Join` + (`registry.join_key(candidate.key, None, cx)`, then open the panel). Rows for keys already + in `registry.groups()` show `Joined` instead. +- Subscribes to `GroupsEvent::Updated` and re-renders. Empty state: `No groups found.`; + before a relay is entered: `Enter a relay to browse its groups.` + +Validation: `cargo check -p workspace`; manually browse a public NIP-29 relay and join from a +result, and create a group on a relay that accepts `9007`. + +--- + +## Phase 4 — details, delete, pins, leave + +Read-mostly group management, the surfaces the sidebar gave up in Phase 2 plus the two writes +that need Phase 0. + +- **Group details** — a dialog or sheet opened from the panel toolbar: name, picture, banner, + about, flags (`private`, `restricted`, `hidden`, `closed`, `livekit`, `parent`, `children`), + admins with their role labels (`Group::admins().entries()`), the roles the relay advertises + (`Group::roles()`), members when the relay publishes `39002` (`Group::members()`), and pins + (`Group::pins()`). Read-only in this phase. +- **Delete a message** — admin-only entry in the message row's context menu (current user in + `admins()`) → `group.delete_event(id, cx)`; Phase 0 makes the row disappear. +- **Pin / unpin** — the same menu: `group.update_pins(pins, cx)` with the current list plus or + minus `Pin::Event(id)`; show `Group::pins()` as a strip above the list. +- **Leave** — toolbar `Leave…` opens a confirmation naming the group (`Leave "Pizza Lovers"?`); + on confirm `registry.leave(&key, cx)` and the panel closes (dispatch `ClosePanel`, as the + screening dialog does). +- **Fork / migration banner** — when `group.elsewhere()` is `Some(moved)`, a banner: + `This group may have moved to {relay}.` with a `Open` button calling + `registry.join_key(moved, None, cx)`. Never auto-join: the NIP asks the client to offer. + +Validation: same commands; manually delete a message, pin and unpin, leave and re-join. + +--- + +## Phase 5 — admin writes + +The remaining moderation surface, all through the calls the backend already exposes. + +- **Edit metadata** — a dialog started from `group.metadata().cloned().unwrap_or_default()`: + name, about, picture, the flags. `children` is preserved as-is — `edit_metadata` re-emits + the full list, and a `9002` that drops one is rejected by the relay. +- **Invite** — `create_invite(code, cx)` with an input or generated code, then + `metadata.naddr(relay, Some(code))` copied to the clipboard (`naddr` needs parsed metadata; + a group whose relay publishes no `39000` cannot mint a link). +- **Users and roles** — add by `npub` → `put_user(pubkey, roles, cx)`, remove → + `remove_user(pubkey, cx)`; role choices come from `Group::roles()`. The relay is the + authority for both the roles it supports and who may call these. +- **React** — the row menu sends `send_reaction(target, emoji, cx)`; a kind `7` row appears + inline (that is the current backend behaviour). + +Validation: same commands; manually exercise each write against a relay that grants the +account admin, and confirm the refusal path shows the relay's message. + +--- + +## Phase 6 — delete the Concord stack + +Nothing in the app may reference Concord before this phase starts. + +1. Delete `crates/community`, `crates/concord`, `crates/community_ui`. +2. `crates/workspace/Cargo.toml`: drop `community` and `community_ui`. + `desktop/Cargo.toml` and `web/Cargo.toml`: drop `community`; `nip29` stays. +3. `desktop/src/main.rs` and `web/src/lib.rs`: drop `community::init(cx)` and its comment. +4. `crates/settings/src/lib.rs`: drop `recent_communities` and `record_recent_community` — + nothing reads them (verified), and the concept no longer exists. +5. `docs/nip29-usage.md`: replace the "Not wired up yet" section. Remaining known gaps then + are: LiveKit's response field names (a guess until checked against a real relay), the AV + room itself, local handling of remote `9005`s, the subgroup tree, and reaction grouping. +6. Confirm no `concord`/`community` string remains outside `docs/` history, and run a full + `cargo check -p coop -p coop_web` plus `cargo clippy --workspace --all-targets`. + +--- + +## Later (not part of the migration) + +- **LiveKit** — `Group::livekit_supported` and `Group::livekit_session` are ready; check + `metadata.livekit`, then build the AV room UI. Verify the token response fields first. +- **Subgroups** — `parent`/`children` are parsed; assemble the tree in the sidebar when the + relay advertises `nip29.subgroups`. +- **Replies and threads** — `send_message` already takes reply ids and `chat::Message` reads + them back; the panel needs a reply affordance and a quoted row. +- **Remote deletions** — fold a `9005` seen live into `Group::forget`, the same eviction as + Phase 0. +- **Recent groups** — if a local recency list is wanted, store group keys, not the removed + `recent_communities` strings. + +## Validation checklist (every phase) + +```sh +cargo clippy -p --all-targets +cargo fmt -p # check the exit code, not the output +cargo check -p coop -p coop_web +``` + +Plus the manual smoke for the phase, run in the app. The wasm target is not build-verified in +this repo; `coop_web` check is the proxy. + +## Decisions taken + +- The tab is renamed `Groups`; the vocabulary is the NIP's. +- The sidebar keeps one level; admins and members move to the details dialog. +- The panel reads `Group` directly and keeps no timeline copy. +- Mentions are rendered (shared `chat_ui::text::rendered_text`); replies and attachments are + not offered for sending in v1. +- `Join` never happens implicitly — not from `10009` alone, and not from `elsewhere()`. +- The old crates are deleted only at the end, so any phase can be stopped and the app still + runs. -- 2.54.0 From dbdfa8ad67528d07e086f06ee9524c750098b0e0 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 30 Sep 2026 14:21:54 +0700 Subject: [PATCH 12/27] handle group delete events by purging local state --- crates/nip29/src/group.rs | 12 +++++++++++ crates/nip29/src/lib.rs | 42 ++++++++++++++++++++++++++++++++++-- crates/nip29/src/protocol.rs | 4 ++++ docs/nip29-usage.md | 13 +++++------ 4 files changed, 61 insertions(+), 10 deletions(-) diff --git a/crates/nip29/src/group.rs b/crates/nip29/src/group.rs index 2a41e138..2b38d146 100644 --- a/crates/nip29/src/group.rs +++ b/crates/nip29/src/group.rs @@ -485,6 +485,18 @@ impl Group { cx.notify(); } + pub(crate) fn forget(&mut self, id: EventId, cx: &mut Context) { + self.window.remove(id); + + if !self.index.remove(&id) { + return; + } + + self.messages.retain(|message| message.id != id); + cx.emit(GroupEvent::Updated); + cx.notify(); + } + pub(crate) fn eose(&mut self, cx: &mut Context) { self.loading = false; cx.emit(GroupEvent::Eose); diff --git a/crates/nip29/src/lib.rs b/crates/nip29/src/lib.rs index be42bdeb..663d3d5b 100644 --- a/crates/nip29/src/lib.rs +++ b/crates/nip29/src/lib.rs @@ -506,8 +506,12 @@ impl GroupsRegistry { key.clone(), cx.subscribe(&group, |this, group, event, cx| match event { GroupEvent::Sent(event) => { - let key = group.read(cx).key().clone(); - this.reload(key, Some(event.clone()), cx); + if event.kind == Kind::GroupDeleteEvent { + this.forget_event(group, event.clone(), cx); + } else { + let key = group.read(cx).key().clone(); + this.reload(key, Some(event.clone()), cx); + } } GroupEvent::Updated => { this.refresh_entry(group, cx); @@ -676,6 +680,40 @@ impl GroupsRegistry { })); } + fn forget_event(&mut self, group: Entity, event: Event, cx: &mut Context) { + let Some(target) = event.tags.event_ids().next() else { + return; + }; + + let key = group.read(cx).key().clone(); + let cache_tag = key.cache_tag(); + + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let purge = cx.background_spawn(async move { + let filter = Filter::new() + .kind(Kind::ApplicationSpecificData) + .identifier(target.to_hex()) + .custom_tag(SingleLetterTag::LOWERCASE_R, cache_tag); + + client.database().delete(filter).await + }); + + self.tasks.push(cx.spawn(async move |this, cx| { + if let Err(error) = purge.await { + log::warn!("nip29: dropping the deleted envelope failed: {error}"); + } + this.update(cx, |this, cx| { + group.update(cx, |group, cx| { + group.forget(target, cx); + }); + this.reload(key, Some(event), cx); + }) + .ok(); + })); + } + fn handle_event(&mut self, id: SubscriptionId, event: Event, cx: &mut Context) { if id == list_subscription_id() { match GroupList::parse(&event) { diff --git a/crates/nip29/src/protocol.rs b/crates/nip29/src/protocol.rs index a1f8bf2a..a5647a74 100644 --- a/crates/nip29/src/protocol.rs +++ b/crates/nip29/src/protocol.rs @@ -819,6 +819,10 @@ impl TimelineWindow { self.0.truncate(TIMELINE_WINDOW); } + pub fn remove(&mut self, id: EventId) { + self.0.retain(|event| event.id != id); + } + pub fn previous_tag(&self, me: PublicKey) -> Option { let references: Vec = self .0 diff --git a/docs/nip29-usage.md b/docs/nip29-usage.md index 7eace0a1..7c4eaa64 100644 --- a/docs/nip29-usage.md +++ b/docs/nip29-usage.md @@ -353,10 +353,9 @@ reject anything it does not want. must carry every child tag, because a `9002` that drops one is rejected by the relay; the builder re-emits them from the struct. -`delete_event` is a **relay** instruction only. The relay stops serving the event, -but the local timeline keeps it: the backend does not evict the envelope or the -message, so a deletion the relay accepted is still visible locally until the UI -phase drops it. That is the one moderation write left incomplete (see below). +`delete_event` sends `9005`, and once the relay accepts it the registry drops the +target's cached envelope, evicts the row and re-reads the group, so the timeline stops +showing it. Remote deletions are not folded in: the standing REQ does not carry `9005`. ## Pins @@ -551,10 +550,8 @@ before they reach the consumer, so a replay cannot double-hydrate. `chat::Message` rendering lifted (or copied) out of `ChatPanel`: the data type is already shared, only the row rendering is not. `crates/workspace/src/panels/browse.rs` is the stub for browsing. -- **`9005` delete does not touch local state.** The relay stops serving the - event, but the envelope and the row stay until a reload. Dropping it needs a - filter on the target's `d` tag plus the group's `r` cache tag, and an eviction - from `messages` and the id index; nothing does that today. +- **Remote deletions are not folded in.** A `9005` from another admin never reaches the + standing REQ, so only our own accepted deletes evict the row. - **The LiveKit response shape is a guess.** The NIP names no fields, so `LivekitSession::parse` reads `token` (or `jwt`) and `url` (or `server_url`) and fails loudly on anything else. Re-check it against the relay actually targeted -- 2.54.0 From 41045d4f884f930aaec9184a28da8384982e767f Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 30 Sep 2026 14:33:04 +0700 Subject: [PATCH 13/27] add nip29_ui crate with group panel --- Cargo.lock | 18 ++ crates/chat_ui/src/lib.rs | 2 +- crates/nip29_ui/Cargo.toml | 20 ++ crates/nip29_ui/src/lib.rs | 553 +++++++++++++++++++++++++++++++++ crates/nip29_ui/src/message.rs | 73 +++++ docs/nip29-usage.md | 8 +- 6 files changed, 669 insertions(+), 5 deletions(-) create mode 100644 crates/nip29_ui/Cargo.toml create mode 100644 crates/nip29_ui/src/lib.rs create mode 100644 crates/nip29_ui/src/message.rs diff --git a/Cargo.lock b/Cargo.lock index 0f1b9e38..80ca011f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4612,6 +4612,24 @@ dependencies = [ "state", ] +[[package]] +name = "nip29_ui" +version = "1.0.2" +dependencies = [ + "anyhow", + "chat", + "chat_ui", + "common", + "gpui-pre", + "nip29", + "nostr-sdk", + "person", + "settings", + "smallvec", + "theme", + "ui", +] + [[package]] name = "no_std_io2" version = "0.9.4" diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 7cdd7670..7c180368 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -53,7 +53,7 @@ static EMOJI_RE: LazyLock = mod actions; mod file; -mod text; +pub mod text; pub fn init(room: WeakEntity, window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| ChatPanel::new(room, window, cx)) diff --git a/crates/nip29_ui/Cargo.toml b/crates/nip29_ui/Cargo.toml new file mode 100644 index 00000000..8ae990b1 --- /dev/null +++ b/crates/nip29_ui/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "nip29_ui" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +chat = { path = "../chat" } +chat_ui = { path = "../chat_ui" } +common = { path = "../common" } +nip29 = { path = "../nip29" } +person = { path = "../person" } +settings = { path = "../settings" } +theme = { path = "../theme" } +ui = { path = "../ui" } + +gpui.workspace = true +nostr-sdk.workspace = true +anyhow.workspace = true +smallvec.workspace = true diff --git a/crates/nip29_ui/src/lib.rs b/crates/nip29_ui/src/lib.rs new file mode 100644 index 00000000..eb779c1b --- /dev/null +++ b/crates/nip29_ui/src/lib.rs @@ -0,0 +1,553 @@ +use std::collections::BTreeMap; + +use anyhow::Result; +use chat::Message; +use chat_ui::text::rendered_text; +use gpui::prelude::FluentBuilder; +use gpui::{ + AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, FollowMode, + IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, Render, SharedString, + Styled, Subscription, Task, WeakEntity, Window, div, list, px, +}; +use nip29::{Group, GroupEvent, GroupsRegistry, Membership}; +use nostr_sdk::prelude::*; +use person::PersonRegistry; +use smallvec::{SmallVec, smallvec}; +use theme::ActiveTheme; +use ui::avatar::Avatar; +use ui::button::{Button, ButtonVariants}; +use ui::dock::{Panel, PanelEvent}; +use ui::input::{InputEvent, Textarea, TextareaState}; +use ui::markdown::RenderedText; +use ui::message::WelcomeMessage; +use ui::notification::Notification; +use ui::scroll::Scrollbar; +use ui::{Disableable, IconName, Sizable, WindowExtension, h_flex, v_flex}; + +mod message; + +const LOAD_OLDER_THRESHOLD: usize = 20; +const RUN_WINDOW_SECS: u64 = 300; + +enum Notice { + Relay(String), + Join { + refused: Option, + closed: bool, + }, + Pending, + Unsupported, +} + +impl Notice { + fn of(group: &Group) -> Option { + if let Some(reason) = group.refused() { + return Some(Self::Relay(reason.to_owned())); + } + + let closed = group.metadata().is_some_and(|metadata| metadata.closed); + + match group.membership() { + Membership::Member => {} + Membership::Pending { .. } => return Some(Self::Pending), + Membership::Refused { reason } => { + return Some(Self::Join { + refused: reason.clone(), + closed, + }); + } + Membership::Unknown | Membership::Removed => { + return Some(Self::Join { + refused: None, + closed, + }); + } + } + + group + .metadata() + .is_some_and(|metadata| !metadata.supports(Kind::ChatMessage)) + .then_some(Self::Unsupported) + } +} + +fn display_name(group: &Group) -> SharedString { + group + .metadata() + .and_then(|metadata| metadata.name()) + .map_or_else( + || SharedString::from(group.key().id().as_str()), + SharedString::from, + ) +} + +fn opens_run(messages: &[Message], index: usize) -> bool { + let Some(current) = messages.get(index) else { + return true; + }; + + let Some(previous) = index.checked_sub(1).and_then(|index| messages.get(index)) else { + return true; + }; + + current.author != previous.author + || current + .created_at + .as_secs() + .saturating_sub(previous.created_at.as_secs()) + > RUN_WINDOW_SECS +} + +pub fn init(group: Entity, window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| GroupPanel::new(group, window, cx)) +} + +pub struct GroupPanel { + id: SharedString, + focus_handle: FocusHandle, + group: WeakEntity, + list_state: ListState, + head: Option, + rendered_texts_by_id: BTreeMap, + input: Entity, + tasks: SmallVec<[Task>; 4]>, + _subscriptions: SmallVec<[Subscription; 3]>, +} + +impl GroupPanel { + pub fn new(group: Entity, window: &mut Window, cx: &mut Context) -> Self { + let (id, name, count, head) = { + let group = group.read(cx); + let messages = group.messages(); + + ( + SharedString::from(format!("group-{}", group.key().cache_tag())), + display_name(group), + messages.len(), + messages.first().map(|message| message.id), + ) + }; + + let input = cx.new(|cx| { + TextareaState::new(window, cx) + .placeholder(format!("Message {name}")) + .auto_grow(1, 20) + .clean_on_escape() + }); + + let mut subscriptions = smallvec![]; + + subscriptions.push( + cx.subscribe_in(&input, window, |this, _input, event, window, cx| { + if let InputEvent::PressEnter { .. } = event { + this.send(window, cx); + } + }), + ); + + subscriptions.push(cx.subscribe(&group, |this, _group, event, cx| { + if !matches!(event, GroupEvent::Sent(_)) { + this.sync(cx); + cx.notify(); + } + })); + + subscriptions.push(cx.observe(&group, |this, _group, cx| { + this.sync(cx); + cx.notify(); + })); + + cx.defer_in(window, |this, _window, cx| { + this.list_state.set_follow_mode(FollowMode::Tail); + this.list_state.set_scroll_handler(cx.listener( + |this, event: &ListScrollEvent, _window, cx| { + if event.visible_range.start <= LOAD_OLDER_THRESHOLD { + this.load_older(cx); + } + }, + )); + }); + + Self { + id, + focus_handle: cx.focus_handle(), + group: group.downgrade(), + list_state: ListState::new(count + 2, ListAlignment::Bottom, px(1024.)), + head, + rendered_texts_by_id: BTreeMap::new(), + input, + tasks: smallvec![], + _subscriptions: subscriptions, + } + } + + fn item_count(&self, cx: &App) -> usize { + self.group + .upgrade() + .map_or(2, |group| group.read(cx).messages().len() + 2) + } + + fn sync(&mut self, cx: &App) { + let Some(group) = self.group.upgrade() else { + return; + }; + + let group = group.read(cx); + let messages = group.messages(); + let head = messages.first().map(|message| message.id); + let total = messages.len() + 2; + let old_total = self.list_state.item_count(); + + if total == old_total { + self.head = head; + return; + } + + let old_len = old_total.saturating_sub(2); + let prepended = self + .head + .and_then(|old_head| messages.iter().position(|message| message.id == old_head)) + .filter(|prepended| prepended + old_len <= messages.len()); + + match prepended { + Some(prepended) => { + if prepended > 0 { + self.list_state.splice(2..2, prepended); + } + + let appended = messages.len() - old_len - prepended; + + if appended > 0 { + let at = old_total + prepended; + self.list_state.splice(at..at, appended); + } + } + None => self.list_state.reset(total), + } + + self.head = head; + } + + fn load_older(&mut self, cx: &mut App) { + let Some(key) = self + .group + .upgrade() + .map(|group| group.read(cx).key().clone()) + else { + return; + }; + + let groups = GroupsRegistry::global(cx); + + groups.update(cx, |registry, cx| { + registry.load_more(&key, cx); + }); + } + + fn send(&mut self, window: &mut Window, cx: &mut Context) { + let content = self.input.read(cx).value().trim().to_owned(); + + if content.is_empty() { + return; + } + + let Ok(send) = self + .group + .update(cx, |group, cx| group.send_message(&content, vec![], cx)) + else { + return; + }; + + self.input.update(cx, |input, cx| { + input.set_value("", window, cx); + }); + + self.tasks + .push(cx.spawn_in::<_, Result<()>>(window, async move |this, cx| { + if let Err(error) = send.await { + this.update_in(cx, |_this, window, cx| { + window.push_notification(Notification::error(error.to_string()), cx); + })?; + } + Ok(()) + })); + } + + fn notice(&self, cx: &App) -> Option { + self.group + .upgrade() + .and_then(|group| Notice::of(group.read(cx))) + } + + fn render_welcome(&self, cx: &App) -> AnyElement { + let (name, avatar) = match self.group.upgrade() { + Some(group) => { + let group = group.read(cx); + let seed = group.key().cache_tag(); + let picture = group + .metadata() + .and_then(|metadata| metadata.picture.clone()); + + ( + display_name(group), + Avatar::from_source(picture).seed(seed).large(), + ) + } + None => (SharedString::from("this group"), Avatar::new(None)), + }; + + WelcomeMessage::new("welcome") + .icon(avatar) + .title(format!("Welcome to {name}")) + .message(format!("This is the start of {name}.")) + .into_any_element() + } + + fn render_older(&self, cx: &mut Context) -> AnyElement { + let Some(group) = self.group.upgrade() else { + return div().into_any_element(); + }; + + let (has_older, loading) = { + let group = group.read(cx); + + (group.oldest().is_some(), group.loading_more()) + }; + + if !has_older { + return div().into_any_element(); + } + + h_flex() + .w_full() + .justify_center() + .py_2() + .child( + Button::new("load-older") + .label(if loading { + "Loading earlier messages…" + } else { + "Load earlier messages" + }) + .ghost() + .small() + .loading(loading) + .disabled(loading) + .on_click(cx.listener(|this, _event, _window, cx| this.load_older(cx))), + ) + .into_any_element() + } + + fn render_notice(&self, notice: &Notice, cx: &mut Context) -> AnyElement { + let row = h_flex() + .w_full() + .justify_center() + .items_center() + .gap_2() + .px_3() + .py_2() + .text_sm() + .text_color(cx.theme().text_placeholder); + + match notice { + Notice::Relay(reason) => row + .child(SharedString::from(reason.as_str())) + .into_any_element(), + Notice::Join { refused, closed } => { + let row = match refused { + Some(reason) => row.child(SharedString::from(reason.as_str())), + None => row, + }; + + if *closed { + return row + .child("This group is closed; an invite link is required.") + .into_any_element(); + } + + let Some(key) = self + .group + .upgrade() + .map(|group| group.read(cx).key().clone()) + else { + return row.into_any_element(); + }; + + row.child( + Button::new("join-group") + .label("Request to join") + .small() + .on_click(cx.listener(move |_this, _event, _window, cx| { + GroupsRegistry::global(cx).update(cx, |registry, cx| { + registry.join_key(key.clone(), None, cx); + }); + })), + ) + .into_any_element() + } + Notice::Pending => row + .child("Waiting for the relay to approve.") + .into_any_element(), + Notice::Unsupported => row + .child("This group doesn't support text messages.") + .into_any_element(), + } + } + + fn render_message( + &mut self, + ix: usize, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + if ix == 0 { + return self.render_welcome(cx); + } + + if ix == 1 { + return self.render_older(cx); + } + + let Some(group) = self.group.upgrade() else { + return div().into_any_element(); + }; + + let group = group.read(cx); + let messages = group.messages(); + + let Some(message) = messages.get(ix - 2) else { + return div().into_any_element(); + }; + + let show_author = opens_run(messages, ix - 2); + let persons = PersonRegistry::global(cx); + + let content = self + .rendered_texts_by_id + .entry(message.id) + .or_insert_with(|| { + rendered_text(&message.content, &message.mentions, &persons, true, cx) + }) + .element(ix.into(), window, cx); + + message::render(ix, message, content, show_author, cx) + } + + fn render_composer(&self, cx: &mut Context) -> impl IntoElement { + let can_send = self.group.upgrade().is_some_and(|group| { + let group = group.read(cx); + let metadata = group.metadata(); + let supports_text = + metadata.is_none_or(|metadata| metadata.supports(Kind::ChatMessage)); + let writable = metadata.is_none_or(|metadata| !metadata.restricted) + || matches!(group.membership(), Membership::Member); + + supports_text && writable + }); + + h_flex() + .flex_shrink_0() + .w_full() + .p_2() + .gap_1() + .items_end() + .child(Textarea::new(&self.input).appearance(false).flex_1()) + .child( + Button::new("send") + .icon(IconName::PaperPlaneFill) + .tooltip("Send") + .ghost() + .large() + .disabled(!can_send) + .on_click(cx.listener(|this, _event, window, cx| { + this.send(window, cx); + })), + ) + } +} + +impl Panel for GroupPanel { + fn panel_id(&self) -> SharedString { + self.id.clone() + } + + fn title(&self, cx: &App) -> AnyElement { + self.group + .upgrade() + .map(|group| { + let group = group.read(cx); + let seed = group.key().cache_tag(); + let picture = group + .metadata() + .and_then(|metadata| metadata.picture.clone()); + + h_flex() + .gap_1() + .text_xs() + .child(Avatar::from_source(picture).seed(seed).xsmall()) + .child(display_name(group)) + .into_any_element() + }) + .unwrap_or_else(|| div().text_xs().child("Unknown").into_any_element()) + } + + fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec