374 lines
19 KiB
Markdown
374 lines
19 KiB
Markdown
# 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<Self>)` (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<Group>`.
|
||
|
||
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<Group>, window: &mut Window, cx: &mut App) -> Entity<GroupPanel>;
|
||
|
||
pub struct GroupPanel {
|
||
id: SharedString, // "group-<cache_tag>"
|
||
focus_handle: FocusHandle,
|
||
group: WeakEntity<Group>,
|
||
list_state: ListState,
|
||
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
||
input: Entity<TextareaState>,
|
||
tasks: SmallVec<[Task<Result<()>>; 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<Group> }`.
|
||
- 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<DockArea>`) 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 <touched-crates> --all-targets
|
||
cargo fmt -p <touched-crates> # 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.
|