add browse panel for discovering relay groups

This commit is contained in:
2026-09-30 15:53:17 +07:00
parent 6e24a6033e
commit 5b87b2fe27
7 changed files with 383 additions and 108 deletions
+55 -28
View File
@@ -58,16 +58,20 @@ subscribes to `GroupsEvent` and renders `groups()` — pending and refused group
are in that list alongside members, and their `Group::membership()` says which.
```rust
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone)]
pub enum GroupsEvent {
Updated,
Error(String),
Browsed(Box<GroupCandidate>),
BrowseSettled(RelayUrl),
}
```
`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.
`Updated` fires on any change to `groups()`; `Error` is only emitted when
publishing a `9007` (create) is refused, and carries the relay's message.
`Browsed` and `BrowseSettled` carry a browse: one group the relay announced, and
the end of the answer (see [Browsing a relay](#browsing-a-relay)). They are
fan-out, not stored — whatever opened the browse owns the candidates.
## Value types
@@ -435,23 +439,35 @@ 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);
// then, in a GroupsEvent handler:
match event {
GroupsEvent::Browsed(candidate) => push(candidate),
GroupsEvent::BrowseSettled(relay) => settled(relay),
_ => {}
}
```
`Browse` is `{ relay, loading, groups: Vec<GroupCandidate> }`, 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)`.
A `GroupCandidate` is a `GroupKey` plus its parsed `GroupMetadata`; `display_name`
falls back to the id and `display_image` is the picture, the same pair `Group`
offers. `browse` connects the relay, sends one auto-closing `39000` REQ (limit
500, EOSE or 10s) and returns immediately; the pump parses each relay-signed
metadata event and re-emits it as `Browsed`.
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.
**The registry stores no browse result.** It keeps only which relay each `brw-`
REQ belongs to — the same routing bookkeeping as `routes`, and the reason
`Browsed` can carry a boxed candidate instead of the registry holding a `Vec`:
the only copy is the caller's. A browse is transient by nature, so the surface
that asked for it owns it (the browse panel keeps `relay`, `candidates` and
`loading` in its own state, and drops them with the panel).
`BrowseSettled` is driven by the subscribe task itself, not by `EOSE`/`CLOSED`
notifications: it is sent once `subscribe(..).await` returns, which happens on
EOSE, on a refusal, or when the auto-close timeout fires. So the answer always
ends, even against a relay that never EOSEs. A candidate can still arrive after
the settle — the pump and the subscribe task are different producers — which is
why the relay stays mapped. Join a result with `join_key(candidate.key, None, cx)`.
## Creating a group
@@ -496,14 +512,22 @@ impl GroupsRegistry {
}
```
- `GroupsRegistry` holds `groups: Vec<Entity<Group>>`, the `10009` `list`, the
live `browse` results, `routes` (subscription id → `GroupKey`), one
`Subscription` per group, the fork state, and its tasks.
- `GroupsRegistry` holds `groups: Vec<Entity<Group>>`, the `10009` `list`,
`routes` (subscription id → `GroupKey`), the relay behind each live `brw-` REQ,
one `Subscription` per group, the fork state, and its tasks. It holds no browse
result: a browse is fan-out, and the caller keeps the candidates.
- `Group` owns everything the panel draws: metadata, admins/members/roles,
participants, pins, membership, the `Vec<Message>` timeline, the `previous`
window, `loading`/`loading_more`/`refused`.
- Views render `Entity<Group>` and re-read on `GroupEvent`; no protocol state
lives in a view.
- The registry re-broadcasts every `GroupEvent::Updated` as `GroupsEvent::Updated`
and notifies itself, so a view that watches only the registry (the sidebar,
which renders `groups()` as rows) stays in sync when a group's metadata,
membership, pins or fork state change. `GroupEvent::Updated` fires for state and
membership only, never for an arriving message — that is `GroupEvent::Incoming`,
and it is why a rebuild of the row list does not happen per message. A view that
renders the timeline watches the `Group` itself.
### Foreground and background
@@ -545,20 +569,23 @@ before they reach the consumer, so a replay cannot double-hydrate.
## Not wired up yet
- **Browsing, details and administration are not built.** The sidebar's `Groups`
tab lists `GroupsRegistry::groups()`, opens a `GroupPanel`, and offers the
new-group and join dialogs, but `crates/workspace/src/panels/browse.rs` is still
a stub, and the group details, moderation and pin surfaces are still to be built.
- **Details and administration are not built.** The sidebar's `Groups` tab lists
`GroupsRegistry::groups()`, opens a `GroupPanel`, and offers the new-group and
join dialogs; `crates/workspace/src/panels/browse.rs` browses a relay and joins
from a result. The group details, moderation and pin surfaces are still to be
built.
- **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
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.
- **A relay that never EOSEs bounds how long "load more" spins.**
`Group::loading_more` relies 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. A browse is fine by
contrast: `BrowseSettled` comes from the subscribe task returning, so it
always lands.
- **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.