add plan
This commit is contained in:
@@ -0,0 +1,257 @@
|
|||||||
|
# Discovering public communities
|
||||||
|
|
||||||
|
How to add a Discover/Browse feed of public communities, modelled on Armada's
|
||||||
|
Discover but **unfiltered by default** — every announced community shows, with no
|
||||||
|
curation allow-list. This document is a plan, not a description of shipped code;
|
||||||
|
nothing below is wired up yet.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Let a user browse communities they are not already in, see their name, icon,
|
||||||
|
member count and last-active time, and join one in a click — without an invite
|
||||||
|
being sent to them directly. Joining stays the existing Concord join path; the new
|
||||||
|
part is the public listing and its discovery.
|
||||||
|
|
||||||
|
## What "all communities" can and cannot mean
|
||||||
|
|
||||||
|
Concord communities are encrypted and have no global public registry. A community
|
||||||
|
exists on the wire only for holders of its `community_root` (see
|
||||||
|
`concord-usage.md`). So a feed can only ever enumerate communities whose owner
|
||||||
|
**explicitly and publicly shared a joinable invite link**. "Show all by default"
|
||||||
|
therefore means: among announced communities, apply no author allow-list — not
|
||||||
|
"enumerate every Concord community".
|
||||||
|
|
||||||
|
Armada gates its feeds on an author allow-list: a kind-39089 follow pack (the
|
||||||
|
Armada team pack by default) plus the viewer and their follows, with
|
||||||
|
`curation: "none"` or `discoverAllContent` removing the filter. We take the
|
||||||
|
filterless branch as the **default** and keep the curated branch as an opt-in
|
||||||
|
filter (or drop it entirely — see Open questions).
|
||||||
|
|
||||||
|
## The convention: a community announcement (kind 3314)
|
||||||
|
|
||||||
|
Not a CORD kind. An announcement is a plain regular event, signed by the sharer,
|
||||||
|
whose **content is a full invite link and nothing else**:
|
||||||
|
|
||||||
|
```
|
||||||
|
3314 | content: "https://host/invite/naddr1…#<fragment>" | tags: []
|
||||||
|
```
|
||||||
|
|
||||||
|
- The link is a bearer secret: anyone who finds it can join, so publishing one is
|
||||||
|
always an explicit user action (there is no "auto-list my community").
|
||||||
|
- Name, icon, channels and relays are **not** in the announcement — they come from
|
||||||
|
resolving the bundle the link points at (kind 33301, `cord05`). This is why an
|
||||||
|
announcement with no valid invite link is dropped.
|
||||||
|
- De-duplication key is the **link signer**: the public key of the kind-33301
|
||||||
|
bundle coordinate's author, parsed out of the naddr. Several announcements of
|
||||||
|
one community (from different sharers, or the same sharer re-posting) fold to
|
||||||
|
one card at render time.
|
||||||
|
- Un-listing is a NIP-09 kind 5 with `e` tags for the announcement ids **and** a
|
||||||
|
`["k", "3314"]` tag. The reader queries deletes by `#k`, so an untagged delete
|
||||||
|
would never be seen. A delete is honored only from the announcement's own
|
||||||
|
author.
|
||||||
|
|
||||||
|
Because the announcement is a convention on top of the existing `cord05` link
|
||||||
|
format, it does **not** belong in `crates/concord` (which stays protocol-only, per
|
||||||
|
`concord-usage.md`). It lives one crate up, in `crates/community`, beside the
|
||||||
|
reader that consumes it.
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
announcement 3314 (content = invite link)
|
||||||
|
│ read (per relay) + kind 5 #k:3314 deletes
|
||||||
|
▼
|
||||||
|
fold: drop deleted-by-author, newest per link signer
|
||||||
|
▼
|
||||||
|
DiscoveredInvite { invite_url, link_signer, source }
|
||||||
|
│ parse_link → fetch 33301 → parse_bundle_event
|
||||||
|
▼
|
||||||
|
CommunityInvite (name, icon, channels, relays, owner)
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
card: name / icon / members activity probe: kind 1059 limit:1
|
||||||
|
▼
|
||||||
|
join → existing Concord join path
|
||||||
|
```
|
||||||
|
|
||||||
|
## Where this lands
|
||||||
|
|
||||||
|
| Concern | Location |
|
||||||
|
| --- | --- |
|
||||||
|
| 3314 build/parse/fold (pure) | new `crates/community/src/discover.rs` |
|
||||||
|
| Relay read, paging, activity, seeds | `crates/community` (a `DiscoverRegistry` beside `CommunityRegistry`) |
|
||||||
|
| Bundle resolve + join | `crates/community/src/sync.rs` (reuse the join-material path) |
|
||||||
|
| UI | `crates/workspace/src/panels/browse.rs` (the current placeholder) |
|
||||||
|
|
||||||
|
## Gap analysis
|
||||||
|
|
||||||
|
Coop already has the Concord substrate but has never wired the invite loop.
|
||||||
|
|
||||||
|
| Capability | Status in coop |
|
||||||
|
| --- | --- |
|
||||||
|
| Invite link build/parse (`cord05`) | present: `build_invite_url`, `parse_link`, `bundle_naddr` |
|
||||||
|
| Encrypted bundle 33301 | present: `build_bundle_event`, `parse_bundle_event`, `CommunityInvite` |
|
||||||
|
| Invite list (kind 13303) | present: `InviteList`, `build`/`parse_invite_list`, `InviteEntry` |
|
||||||
|
| **Create + share an invite** | **missing** — no production caller of the writers; no UI |
|
||||||
|
| **Join by invite link** | **missing** — `parse_link` used only in `cord05` tests |
|
||||||
|
| Announcement 3314 | missing |
|
||||||
|
| Discover reader / feed | missing |
|
||||||
|
| Discover UI | placeholder only — `BrowsePanel` renders its own name |
|
||||||
|
|
||||||
|
The invite loop is the prerequisite: until coop can mint a link and join by one,
|
||||||
|
there is nothing publishable to discover.
|
||||||
|
|
||||||
|
## Phase 0 — Invite links end-to-end
|
||||||
|
|
||||||
|
Without this, Discover has no supply.
|
||||||
|
|
||||||
|
**Mint a link** (owner action, from a `CommunityState`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use concord::derive::invite_bundle_key;
|
||||||
|
use concord::cord05::{self, CommunityInvite, InviteEntry};
|
||||||
|
|
||||||
|
let token: [u8; concord::derive::TOKEN_LEN] = /* CSPRNG */;
|
||||||
|
let bundle_key = invite_bundle_key(&token);
|
||||||
|
let link_signer = Keys::generate();
|
||||||
|
|
||||||
|
let invite = CommunityInvite { /* from state: community_id, owner, owner_salt,
|
||||||
|
community_root, root_epoch, control_pk, channels, relays, name, icon */ };
|
||||||
|
let bundle = cord05::build_bundle_event(&link_signer, &invite, &bundle_key)?;
|
||||||
|
let url = cord05::build_invite_url(BASE, &link_signer.public_key(), &token, &relays)?;
|
||||||
|
|
||||||
|
// publish `bundle` to the community's relays, then keep the link local:
|
||||||
|
let entry = 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(),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Persist the entry in the member's own Invite List (kind 13303, `build_invite_list`)
|
||||||
|
so it syncs across devices and can later be refreshed or retired (a tombstone,
|
||||||
|
never a deletion). `CommunityState` already carries every field `CommunityInvite`
|
||||||
|
needs (`owner`, `owner_salt`, `community_root`, `root_epoch`, `control_pks`,
|
||||||
|
`channels`, `relays`, `name`, `icon`), so this is assembly plus publish — no new
|
||||||
|
protocol.
|
||||||
|
|
||||||
|
**Resolve + join a link** (member action). Add a resolver beside the reader:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let link = cord05::parse_link(url)?; // link_signer, token, bootstrap_relays, naddr
|
||||||
|
let bundle_key = invite_bundle_key(&link.token);
|
||||||
|
// fetch kind 33301 at (author = link.link_signer, d = "") from bootstrap_relays:
|
||||||
|
let invite = match cord05::parse_bundle_event(&event, &link.link_signer, &bundle_key)? {
|
||||||
|
BundleState::Live(invite) => invite,
|
||||||
|
BundleState::Revoked => return Ok(None),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Then materialize and announce the membership exactly as `concord-usage.md`'s
|
||||||
|
"Joining" section does: `cord02::list::join_material(&invite, None)` →
|
||||||
|
`CommunityState::from_join_material`, publish the guestbook join
|
||||||
|
(`cord02::guestbook::build_join` + `seal_rumor`), and record the membership the
|
||||||
|
way `sync::create` does (`record_membership`). Reuse the `adopt_list_material` /
|
||||||
|
`retain_join_root` helpers in `crates/community/src/sync.rs`.
|
||||||
|
|
||||||
|
**UI** for Phase 0: a "Copy invite link" action on a community (panel toolbar or
|
||||||
|
context menu) and a paste-link join dialog under `crates/workspace/src/dialogs/`.
|
||||||
|
A deep link (`https://host/invite/…#…`, or a `coop://` scheme) can feed the same
|
||||||
|
dialog.
|
||||||
|
|
||||||
|
## Phase 1 — The 3314 conventions (`crates/community/src/discover.rs`)
|
||||||
|
|
||||||
|
A pure module — no I/O, mirroring `cord05`'s split between parse and fetch:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub const KIND_COMMUNITY_ANNOUNCEMENT: u16 = 3314;
|
||||||
|
|
||||||
|
pub struct DiscoveredInvite {
|
||||||
|
pub invite_url: String,
|
||||||
|
pub link_signer: PublicKey, // de-dup key
|
||||||
|
pub source: Event,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extract_invite_urls(text: &str) -> Vec<String>;
|
||||||
|
pub fn announcement_from_event(event: &Event) -> Option<DiscoveredInvite>;
|
||||||
|
pub fn build_community_announcement(invite_url: &str) -> Option<EventBuilder>;
|
||||||
|
pub fn build_announcement_deletion(ids: &[EventId]) -> EventBuilder; // kind 5 + ["k","3314"]
|
||||||
|
|
||||||
|
/// Newest per link signer, honoring deletes only from the announcement's author.
|
||||||
|
pub fn fold_announcements(announcements: &[Event], deletes: &[Event]) -> Vec<DiscoveredInvite>;
|
||||||
|
```
|
||||||
|
|
||||||
|
The invite-URL matcher accepts both the absolute form and the bare `naddr1…#frag`
|
||||||
|
form found in free text, and validates each candidate with `parse_link` so only
|
||||||
|
secret-carrying links survive.
|
||||||
|
|
||||||
|
## Phase 2 — The reader (`crates/community`)
|
||||||
|
|
||||||
|
A `DiscoverRegistry`, global like `CommunityRegistry`, owning the network side:
|
||||||
|
|
||||||
|
- **Relays.** The account's NIP-65 read relays plus any configured app relays.
|
||||||
|
The fragment's `bootstrap_relays` (or `cord05::stock_relays()`) are used only to
|
||||||
|
fetch the bundle, never to enumerate.
|
||||||
|
- **Read + page.** Per relay (a dead relay must cost only its own events): kind
|
||||||
|
3314, plus kind 5 with `#k: ["3314"]`. Page with an `until` cursor so "load
|
||||||
|
more" walks older announcements; dedupe by link signer across pages.
|
||||||
|
- **Resolve bundles.** Fetch kind 33301 at the link coordinate, decrypt with the
|
||||||
|
token-derived key, cache by link signer (name/icon feed the cards; missing
|
||||||
|
bundle ⇒ an "encrypted community" placeholder, as Armada does).
|
||||||
|
- **Activity.** A batched `limit: 1` kind-1059 probe per community — stream
|
||||||
|
addresses are derivable from the bundle (`derive::{guestbook_group_key,
|
||||||
|
control_group_key, channel_group_key}`) without joining. Cap filters per REQ and
|
||||||
|
bound `until` to now, since a future-dated wrap would otherwise pin "Active now".
|
||||||
|
- **Warm paint.** Seed the last good directory so the first paint is not a
|
||||||
|
skeleton; a seed is stale-but-usable and the network read stays authoritative.
|
||||||
|
Store it wherever `community::cache` stores local state, or in the client
|
||||||
|
database.
|
||||||
|
- **No allow-list.** The default path applies no `authors` filter and never reads
|
||||||
|
a curation pack. An opt-in curated mode can be added later behind a setting.
|
||||||
|
- Emit `CommunityEvent`-style notifications so the sidebar/panel re-render.
|
||||||
|
|
||||||
|
## Phase 3 — UI (`BrowsePanel`)
|
||||||
|
|
||||||
|
Turn the placeholder `crates/workspace/src/panels/browse.rs` (already reached
|
||||||
|
from the sidebar "Browse" `NavItem` → `Command::ShowBrowse`) into the feed:
|
||||||
|
|
||||||
|
- A scrolling list of cards: icon, name, member count, "Active N ago", and a Join
|
||||||
|
button that runs the Phase 0 resolve-and-join on the card's link.
|
||||||
|
- Loading skeletons, an empty state, and an error state that only shows when
|
||||||
|
there is nothing already painted.
|
||||||
|
- A search box (client-side over the resolved name, since announcements carry no
|
||||||
|
metadata).
|
||||||
|
- A "Share to Discover" dialog from a community the user can publish for, and a
|
||||||
|
"Remove from Discover" that publishes the kind-5 deletion for their own
|
||||||
|
announcements.
|
||||||
|
- Settings: optionally a curation-source field with the default **off**
|
||||||
|
(show all). Rename the panel/sidebar item to "Discover" if desired.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `fold_announcements`: newest-per-link-signer, delete honored only from the same
|
||||||
|
author, delete-via-`#k` tag path.
|
||||||
|
- Invite-URL extraction: absolute and bare forms, rejects non-invite naddrs.
|
||||||
|
- Bundle resolve: live vs revoked tombstone; missing bundle.
|
||||||
|
- Paging: `until` cursor, per-relay failure isolation, cross-page dedupe.
|
||||||
|
- Activity batching: filters-per-REQ cap, future-dated wrap rejected.
|
||||||
|
- A GPUI integration test driving `BrowsePanel` from a seeded registry.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
1. **Discover relays** — account NIP-65 read relays + configured app relays, or a
|
||||||
|
hardcoded Concord bootstrap set?
|
||||||
|
2. **Curation** — ship purely unfiltered, or keep Armada's opt-in author filter
|
||||||
|
(default off) for users who want a curated feed?
|
||||||
|
3. **Activity in v1** — include the kind-1059 probe now, or ship name/icon/members
|
||||||
|
first and add "Active N ago" later?
|
||||||
|
4. **Naming** — keep the existing "Browse" label, or rename to "Discover"?
|
||||||
|
5. **Deletion relay reach** — the kind-5 `#k` read must run against the same relay
|
||||||
|
set as the 3314 read, or an un-list can be missed. Confirm one set is used for
|
||||||
|
both.
|
||||||
|
|
||||||
Reference in New Issue
Block a user