19 KiB
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/nip29andcrates/nip29_ui. Never create agroupscrate. - Implementation-block style: methods on the type they belong to,
selffirst. - 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 foregroundcx.spawntask; 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, nolet _ =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,themetokens). Copy is sentence case; a dialog names its object (Leave "Pizza Lovers"?with aLeavebutton). Monitorstays 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.
TimelineWindow::remove(&mut self, id: EventId)— drop the reference so aprevioustag never points at a deleted event.Group::forget(&mut self, id: EventId, cx: &mut Context<Self>)(crate-private) —messages.retain,index.remove,window.remove; when something was removed, emitGroupEvent::Updatedandcx.notify().hydratestays additive, so a later reload cannot resurrect the row once its envelope is gone.- The registry's ingestion of our accepted delete: in
ensure'sGroupEvent::Sentarm, whenevent.kind == Kind::Custom(9005), take the target fromevent.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, thengroup.forget(target)and the usualreload(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.
# 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.
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 —
WelcomeMessagewith the group avatar;Welcome to {name}andThis is the start of {name}. - Load older — visible when
group.oldest().is_some(), disabled whilegroup.loading_more(), click →GroupsRegistry::global(cx).update(|registry, cx| registry.load_more(&key, cx)). - Message —
MessageRowwith avatar, author (PersonRegistry),created_at.to_human_time(), andchat_ui::text::rendered_text(&message.content, &message.mentions, &persons, true, cx)cached inrendered_texts_by_id. Show the author on a run start (same author, gap under a few minutes) likecommunity_uidoes. Rendermessage.mediaas 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:
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 inrender_user.renderloses its community branch. rows_for(Groups)readsGroupsRegistry::global(cx).read(cx).groups()in registry order (the10009order, then joins).- The group row: name from
metadata.name(), falling back tokey.id().as_str(); avatar frommetadata.picture, seeded withkey.cache_tag(); suffix for the membership state —Requested(Pending),Refused,Removed, andMovedwhenelsewhere().is_some(); nothing for a plain member. Click opensPanelHandle::new(nip29_ui::init(group.clone(), window, cx))inDockPlacement::Center. - Subscriptions: replace the
CommunityRegistrysubscription withGroupsRegistry—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.rsand itspub modline — 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:
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:
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+ aGobutton; Enter submits.RelayUrl::parse→registry.browse(relay, cx). - Body:
registry.browsed(&relay)— a spinner whileBrowse::loading, then rows ofGroupCandidate: avatar, name (or id),id@relay, andJoin(registry.join_key(candidate.key, None, cx), then open the panel). Rows for keys already inregistry.groups()showJoinedinstead. - Subscribes to
GroupsEvent::Updatedand 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 publishes39002(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 minusPin::Event(id); showGroup::pins()as a strip above the list. - Leave — toolbar
Leave…opens a confirmation naming the group (Leave "Pizza Lovers"?); on confirmregistry.leave(&key, cx)and the panel closes (dispatchClosePanel, as the screening dialog does). - Fork / migration banner — when
group.elsewhere()isSome(moved), a banner:This group may have moved to {relay}.with aOpenbutton callingregistry.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.childrenis preserved as-is —edit_metadatare-emits the full list, and a9002that drops one is rejected by the relay. - Invite —
create_invite(code, cx)with an input or generated code, thenmetadata.naddr(relay, Some(code))copied to the clipboard (naddrneeds parsed metadata; a group whose relay publishes no39000cannot mint a link). - Users and roles — add by
npub→put_user(pubkey, roles, cx), remove →remove_user(pubkey, cx); role choices come fromGroup::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 kind7row 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.
- Delete
crates/community,crates/concord,crates/community_ui. crates/workspace/Cargo.toml: dropcommunityandcommunity_ui.desktop/Cargo.tomlandweb/Cargo.toml: dropcommunity;nip29stays.desktop/src/main.rsandweb/src/lib.rs: dropcommunity::init(cx)and its comment.crates/settings/src/lib.rs: droprecent_communitiesandrecord_recent_community— nothing reads them (verified), and the concept no longer exists.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 remote9005s, the subgroup tree, and reaction grouping.- Confirm no
concord/communitystring remains outsidedocs/history, and run a fullcargo check -p coop -p coop_webpluscargo clippy --workspace --all-targets.
Later (not part of the migration)
- LiveKit —
Group::livekit_supportedandGroup::livekit_sessionare ready; checkmetadata.livekit, then build the AV room UI. Verify the token response fields first. - Subgroups —
parent/childrenare parsed; assemble the tree in the sidebar when the relay advertisesnip29.subgroups. - Replies and threads —
send_messagealready takes reply ids andchat::Messagereads them back; the panel needs a reply affordance and a quoted row. - Remote deletions — fold a
9005seen live intoGroup::forget, the same eviction as Phase 0. - Recent groups — if a local recency list is wanted, store group keys, not the removed
recent_communitiesstrings.
Validation checklist (every phase)
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
Groupdirectly and keeps no timeline copy. - Mentions are rendered (shared
chat_ui::text::rendered_text); replies and attachments are not offered for sending in v1. Joinnever happens implicitly — not from10009alone, and not fromelsewhere().- The old crates are deleted only at the end, so any phase can be stopped and the app still runs.