add group activity rows to nip29 timeline

This commit is contained in:
2026-10-01 04:43:20 +07:00
parent 41e48de4fd
commit 61aa7c4a70
6 changed files with 579 additions and 99 deletions
+33 -14
View File
@@ -178,7 +178,7 @@ use nip29::{Group, GroupEvent};
let Some(group) = registry.read(cx).group(&key, cx) else {
return;
};
let messages = group.read(cx).messages(); // &[chat::Message]
let rows = group.read(cx).rows(); // &[nip29::Row]
```
```rust
@@ -194,17 +194,26 @@ cx.subscribe(&group, |this, group, event, cx| match event {
| Variant | Means |
| --- | --- |
| `Incoming(Event)` | a fresh renderable message was folded in (use it for system notifications when the window is inactive) |
| `Incoming(Event)` | a fresh renderable row 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`,
`participants`, `pins`, `membership`, `elsewhere`, `rows`, `loading`,
`loading_more`, `oldest`, `refused`. `display_name` reads the group's name and
falls back to its id; `display_image` is the picture, for a row or avatar.
`rows` is the whole timeline in `created_at` order, one entry per renderable
event. `Row::Message(Box<chat::Message>)` is a chat row; `Row::Activity(Activity)`
is a moderation row — who joined (`9021`), who left (`9022`), who was removed
(`9001`) and who was given roles (`9000` with roles; a `9000` without roles is the
relay echoing a join we already show and is not a row). `Activity::subject()` is
the affected person (the `p` tag target, or the author when there is none) and
`Activity::roles` the assigned roles, so the view only chooses wording and an
icon. `Group::message(id)` looks a chat row up for a pin label.
### The read model
There is **no `fetch_events` anywhere** in the crate. A group is read by
@@ -213,7 +222,7 @@ and `crates/community`:
```
subscribe grp-<hash> the global pump observes every REQ on the client
│ (timeline + state + membership filters, one relay)
│ (timeline + membership + one state filter per kind, one relay)
▼
save_envelope 30078, d = event id, r = "<relay>|<id>", k = kind
│ content = the raw event JSON, signed by crate-local keys
@@ -224,9 +233,13 @@ 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
The standing REQ carries the timeline (`GroupId::timeline_filter(100)`), our own
membership (`membership_filter(me)`) and `state_filters()` — **one filter per
state kind**, because a relay that serves group metadata is allowed to refuse a
filter that mixes metadata kinds with other kinds (`groups.hzrd149.com` answers
`blocked: it's not allowed to mix metadata kinds with others` and drops the whole
REQ, which is what hid a group's name and picture until the filters were split).
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.
@@ -242,7 +255,10 @@ page, a live tail and a local send all take the same path.
- `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.
- `RENDER_KINDS` → a `Row::Message`, inserted in `created_at` order.
- `ACTIVITY_KINDS` → a `Row::Activity`, inserted in `created_at` order. A `9001`
that answers a `9022` from the same subject within five minutes is dropped, so a
plain leave reads once.
- everything else is kept only for the `previous` window.
Every `h`-tagged event feeds the window regardless of kind. The window is the
@@ -517,7 +533,7 @@ impl GroupsRegistry {
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`
participants, pins, membership, the `Vec<Row>` timeline, the `previous`
window, `loading`/`loading_more`/`refused`.
- Views render `Entity<Group>` and re-read on `GroupEvent`; no protocol state
lives in a view.
@@ -525,8 +541,8 @@ impl GroupsRegistry {
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
membership only, never for an arriving row — that is `GroupEvent::Incoming`,
and it is why a rebuild of the row list does not happen per row. A view that
renders the timeline watches the `Group` itself.
### Foreground and background
@@ -550,14 +566,14 @@ notifications() ──sig──► flume ──recv──► this.update(cx, …
### 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:
One `Client` and one notification stream are shared with `chat`, 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-<hash>` | a group's standing REQ (timeline + state + membership) |
| `grp-<hash>` | a group's standing REQ (timeline + membership + state filters) |
| `pg-<hash>` | a "load more" page |
| `brw-<hash>` | a relay browse |
@@ -575,6 +591,9 @@ before they reach the consumer, so a replay cannot double-hydrate.
from a result. The panel has a read-only details dialog, a header `Manage` menu
(edit metadata, invite link, members and roles) shown to admins, a message menu
with reactions and admin pin/delete, a pins strip, a fork banner, and leave.
Opening a group also fills the sidebar with the group itself — avatar, banner
and the collapsible `Admins`/`Members` lists — with a back button in the title
bar, the same shape the sidebar used for a community before the migration.
What a given account may actually do is the relay's call; the client shows the
refusal verbatim.
- **Remote deletions are not folded in.** A `9005` from another admin never reaches the