Files
coop/docs/discover-communities-plan.md
T
2026-09-29 13:48:08 +07:00

12 KiB

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.

Without this, Discover has no supply.

Mint a link (owner action, from a CommunityState):

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:

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:

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.