Compare commits
3
Commits
9a391ca76d
...
cd3f068c5e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd3f068c5e | ||
|
|
cbb97e471f | ||
|
|
97e7539cea |
@@ -1,7 +1,8 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use concord::cord02::ControlFold;
|
||||
use concord::cord02::{ControlFold, ImageRef};
|
||||
use concord::store::{ChannelKeyRef, CommunityState};
|
||||
use concord::{ChannelId, CommunityId, Epoch};
|
||||
use gpui::{AppContext, Context, EventEmitter, Task};
|
||||
@@ -45,8 +46,11 @@ pub struct Community {
|
||||
state: CommunityState,
|
||||
control: ControlFold,
|
||||
members: BTreeSet<PublicKey>,
|
||||
icon: Option<PathBuf>,
|
||||
icon_ref: Option<ImageRef>,
|
||||
dirty: bool,
|
||||
refresh_task: Option<Task<Result<()>>>,
|
||||
icon_task: Option<Task<Result<()>>>,
|
||||
}
|
||||
|
||||
impl EventEmitter<CommunityEvent> for Community {}
|
||||
@@ -57,8 +61,11 @@ impl Community {
|
||||
state,
|
||||
control: ControlFold::default(),
|
||||
members: BTreeSet::new(),
|
||||
icon: None,
|
||||
icon_ref: None,
|
||||
dirty: false,
|
||||
refresh_task: None,
|
||||
icon_task: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,16 +78,25 @@ impl Community {
|
||||
}
|
||||
|
||||
pub fn name(&self) -> String {
|
||||
match &self.control.community {
|
||||
Some(metadata) => metadata.name.clone(),
|
||||
None => self.state.id.to_hex(),
|
||||
if let Some(metadata) = &self.control.community {
|
||||
return metadata.name.clone();
|
||||
}
|
||||
|
||||
self.state
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.state.id.to_hex())
|
||||
}
|
||||
|
||||
pub fn control(&self) -> &ControlFold {
|
||||
&self.control
|
||||
}
|
||||
|
||||
/// The community's icon, once downloaded and decrypted into a cache file.
|
||||
pub fn icon(&self) -> Option<PathBuf> {
|
||||
self.icon.clone()
|
||||
}
|
||||
|
||||
pub fn members(&self) -> &BTreeSet<PublicKey> {
|
||||
&self.members
|
||||
}
|
||||
@@ -121,6 +137,7 @@ impl Community {
|
||||
self.state = snapshot.state;
|
||||
self.control = snapshot.control;
|
||||
self.members = snapshot.members;
|
||||
self.load_icon(cx);
|
||||
cx.emit(CommunityEvent::Updated(self.state.id));
|
||||
cx.notify();
|
||||
}
|
||||
@@ -133,4 +150,37 @@ impl Community {
|
||||
self.refresh(cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the folded icon into a local file.
|
||||
fn load_icon(&mut self, cx: &mut Context<Self>) {
|
||||
let icon = self
|
||||
.control
|
||||
.community
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.icon.clone());
|
||||
|
||||
if self.icon_ref == icon {
|
||||
return;
|
||||
}
|
||||
|
||||
self.icon_ref = icon.clone();
|
||||
self.icon = None;
|
||||
|
||||
let Some(icon) = icon else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.icon_task = Some(cx.spawn(async move |this, cx| {
|
||||
match sync::resolve_icon(&icon, cx).await {
|
||||
Ok(path) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.icon = Some(path);
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
Err(error) => log::warn!("community icon: {error}"),
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,7 +357,6 @@ async fn subscribe(
|
||||
relays: &[RelayUrl],
|
||||
filter: Filter,
|
||||
) -> Result<()> {
|
||||
log::info!("community {id}: subscribing to {relays:?}");
|
||||
client.unsubscribe(id).await?;
|
||||
|
||||
for url in relays {
|
||||
|
||||
+367
-29
@@ -1,9 +1,10 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use concord::cord01::KIND_WRAP;
|
||||
use concord::cord02::list::{CommunityList, KIND_COMMUNITY_LIST};
|
||||
use concord::cord02::{self, ControlFold};
|
||||
use concord::cord02::{self, ControlFold, ImageRef};
|
||||
use concord::cord04::AuthorityCitation;
|
||||
use concord::cord04::roles::{Permissions, citation_ok};
|
||||
use concord::derive::{
|
||||
@@ -11,6 +12,7 @@ use concord::derive::{
|
||||
};
|
||||
use concord::store::{self, CommunityState};
|
||||
use concord::{ChannelId, CommunityId, Epoch, GroupKey};
|
||||
use gpui::AsyncApp;
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::UniversalSigner;
|
||||
|
||||
@@ -42,6 +44,15 @@ pub fn planes(state: &CommunityState) -> Result<Vec<Plane>> {
|
||||
});
|
||||
}
|
||||
|
||||
if state.control_pks.is_empty() {
|
||||
let group = control_group_key(&state.community_root, &state.id, state.root_epoch)?;
|
||||
planes.push(Plane {
|
||||
kind: PlaneKind::Control(state.root_epoch),
|
||||
address: group.pk(),
|
||||
group,
|
||||
});
|
||||
}
|
||||
|
||||
let group = guestbook_group_key(&state.community_root, &state.id, state.root_epoch)?;
|
||||
planes.push(Plane {
|
||||
kind: PlaneKind::Guestbook,
|
||||
@@ -65,24 +76,25 @@ pub fn planes(state: &CommunityState) -> Result<Vec<Plane>> {
|
||||
Ok(planes)
|
||||
}
|
||||
|
||||
/// One `Filter` covering every held plane. The address is the event author,
|
||||
/// not a `p` tag: a Concord wrap's `p` tag carries a random ephemeral key.
|
||||
pub fn subscription_filter(planes: &[Plane]) -> Filter {
|
||||
Filter::new()
|
||||
.kinds([Kind::from(KIND_WRAP)])
|
||||
.authors(planes.iter().map(|plane| plane.address))
|
||||
}
|
||||
|
||||
/// The subscription id carrying a community's planes.
|
||||
pub fn subscription_id(id: &CommunityId) -> SubscriptionId {
|
||||
SubscriptionId::new(format!("{}{}", store::STATE_PREFIX, id.to_hex()))
|
||||
SubscriptionId::new(id.to_hex())
|
||||
}
|
||||
|
||||
pub fn community_of(subscription_id: &SubscriptionId) -> Option<CommunityId> {
|
||||
subscription_id
|
||||
.as_str()
|
||||
.strip_prefix(store::STATE_PREFIX)?
|
||||
.parse()
|
||||
.ok()
|
||||
subscription_id.as_str().parse().ok()
|
||||
}
|
||||
|
||||
/// Download and decrypt a community icon into a content-addressed cache file.
|
||||
pub async fn resolve_icon(icon: &ImageRef, cx: &AsyncApp) -> Result<PathBuf> {
|
||||
let url = Url::parse(&icon.url).context("community icon url")?;
|
||||
state::download_and_decrypt_to_cache(&url, &icon.key, &icon.nonce, &icon.hash, cx).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -92,14 +104,13 @@ pub struct Snapshot {
|
||||
pub members: BTreeSet<PublicKey>,
|
||||
}
|
||||
|
||||
/// Mints a community owned by `signer` and persists it locally.
|
||||
pub async fn create<S>(
|
||||
client: &Client,
|
||||
signer: &S,
|
||||
metadata: &cord02::CommunityMetadata,
|
||||
) -> Result<CommunityState>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let at_secs = Timestamp::now().as_secs();
|
||||
let genesis = cord02::genesis(signer, metadata, at_secs).await?;
|
||||
@@ -112,15 +123,108 @@ where
|
||||
|
||||
for wrap in &genesis.wraps {
|
||||
editions.push(cord02::open_edition(wrap, &read, &address, true)?);
|
||||
client.database().save_event(wrap).await?;
|
||||
}
|
||||
|
||||
let state = CommunityState::from_genesis(&genesis, &editions, at_secs.saturating_mul(1000))?;
|
||||
store::save_state(client, &state).await?;
|
||||
|
||||
publish_wraps(client, &genesis.wraps, &state.relays).await;
|
||||
|
||||
if let Err(error) = record_membership(client, signer, &state, &metadata.name).await {
|
||||
log::warn!(
|
||||
"community {}: recording the membership failed: {error}",
|
||||
state.id.to_hex()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
/// Best-effort publication of the genesis wraps to the community's relays.
|
||||
async fn publish_wraps(client: &Client, wraps: &[Event], relays: &[RelayUrl]) {
|
||||
for url in relays {
|
||||
if let Err(error) = client.add_relay(url).and_connect().await {
|
||||
log::warn!("community genesis: failed to add relay {url}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
for wrap in wraps {
|
||||
let sent = if relays.is_empty() {
|
||||
client.send_event(wrap).broadcast().await
|
||||
} else {
|
||||
client.send_event(wrap).to(relays.iter().cloned()).await
|
||||
};
|
||||
|
||||
match sent {
|
||||
Ok(output) if output.failed.is_empty() => {}
|
||||
Ok(output) => log::warn!(
|
||||
"community genesis: {} relay(s) rejected {}",
|
||||
output.failed.len(),
|
||||
wrap.id
|
||||
),
|
||||
Err(error) => log::warn!("community genesis: publishing {} failed: {error}", wrap.id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn record_membership<S>(
|
||||
client: &Client,
|
||||
signer: &S,
|
||||
state: &CommunityState,
|
||||
name: &str,
|
||||
) -> Result<()>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let self_pk = signer.get_public_key_async().await?;
|
||||
let held = load_list(client, signer, self_pk).await?;
|
||||
|
||||
let frags = held.as_ref().map_or(1, |list| list.frags);
|
||||
|
||||
if frags > 1 {
|
||||
log::warn!(
|
||||
"community {}: the list spans {frags} fragments; deferring the membership write",
|
||||
state.id.to_hex()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let entry = store::list_entry(state, name);
|
||||
let list = match held {
|
||||
Some(held) => held.joined(entry),
|
||||
None => CommunityList::default().joined(entry),
|
||||
};
|
||||
|
||||
let previous = newest_fragment_at(client, self_pk).await?;
|
||||
let now = Timestamp::now().as_secs();
|
||||
let at_secs = previous.map_or(now, |previous| now.max(previous.as_secs() + 1));
|
||||
|
||||
let event = cord02::list::build_list_event(signer, &list, 0, at_secs).await?;
|
||||
publish_list(client, &event).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn publish_list(client: &Client, event: &Event) {
|
||||
match client.send_event(event).to_nip65().await {
|
||||
Ok(output) if output.failed.is_empty() => {}
|
||||
Ok(output) => log::warn!(
|
||||
"community list: {} relay(s) rejected the publish",
|
||||
output.failed.len()
|
||||
),
|
||||
Err(error) => log::warn!("community list: publish failed: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The newest `created_at` the account holds across its list fragments.
|
||||
async fn newest_fragment_at(client: &Client, self_pk: PublicKey) -> Result<Option<Timestamp>> {
|
||||
Ok(newest_fragments(client, self_pk)
|
||||
.await?
|
||||
.into_values()
|
||||
.map(|event| event.created_at)
|
||||
.max())
|
||||
}
|
||||
|
||||
/// The subscription id carrying the account's own Community List.
|
||||
pub const LIST_SUBSCRIPTION: &str = "concord/list";
|
||||
|
||||
@@ -225,6 +329,10 @@ fn refresh(mut held: CommunityState, fresh: CommunityState) -> CommunityState {
|
||||
held.control_root = fresh.control_root;
|
||||
}
|
||||
|
||||
if let Some(name) = fresh.name {
|
||||
held.name = Some(name);
|
||||
}
|
||||
|
||||
for (epoch, address) in fresh.control_pks {
|
||||
held.control_pks.insert(epoch, address);
|
||||
}
|
||||
@@ -249,12 +357,8 @@ fn refresh(mut held: CommunityState, fresh: CommunityState) -> CommunityState {
|
||||
held
|
||||
}
|
||||
|
||||
/// Every fragment of the account's list in the local database, merged.
|
||||
async fn load_list(
|
||||
client: &Client,
|
||||
signer: &UniversalSigner,
|
||||
self_pk: PublicKey,
|
||||
) -> Result<Option<CommunityList>> {
|
||||
/// The newest held copy of each fragment, keyed by its `d` index.
|
||||
async fn newest_fragments(client: &Client, self_pk: PublicKey) -> Result<BTreeMap<u64, Event>> {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(KIND_COMMUNITY_LIST))
|
||||
.author(self_pk);
|
||||
@@ -274,9 +378,21 @@ async fn load_list(
|
||||
}
|
||||
}
|
||||
|
||||
Ok(newest)
|
||||
}
|
||||
|
||||
/// Every fragment of the account's list in the local database, merged.
|
||||
async fn load_list<S>(
|
||||
client: &Client,
|
||||
signer: &S,
|
||||
self_pk: PublicKey,
|
||||
) -> Result<Option<CommunityList>>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let mut merged: Option<CommunityList> = None;
|
||||
|
||||
for event in newest.into_values() {
|
||||
for event in newest_fragments(client, self_pk).await?.into_values() {
|
||||
match cord02::list::parse_list_event(signer, &event).await {
|
||||
Ok(list) => {
|
||||
merged = Some(match merged {
|
||||
@@ -417,6 +533,7 @@ mod tests {
|
||||
|
||||
let state = CommunityState {
|
||||
id: CommunityId::from_bytes([0x42; 32]),
|
||||
name: Some("Anime and Manga".to_owned()),
|
||||
owner,
|
||||
owner_salt: [0x01; 32],
|
||||
community_root: [0x02; 32],
|
||||
@@ -448,9 +565,6 @@ mod tests {
|
||||
|
||||
let planes = planes(&state).expect("planes");
|
||||
|
||||
// Control at the root epoch, the guestbook, and the public channel. The
|
||||
// private channel is skipped: its address derives from the granted key,
|
||||
// not the community_root.
|
||||
assert_eq!(planes.len(), 3);
|
||||
assert!(planes.iter().any(|plane| plane.address == control_pk));
|
||||
assert!(
|
||||
@@ -464,22 +578,56 @@ mod tests {
|
||||
.any(|plane| matches!(plane.kind, PlaneKind::Channel(id, _) if id == general))
|
||||
);
|
||||
|
||||
// The filter author-lists every plane, so the subscription actually
|
||||
// reaches the events the fold reads.
|
||||
let filter = subscription_filter(&planes);
|
||||
let addresses: BTreeSet<PublicKey> = planes.iter().map(|plane| plane.address).collect();
|
||||
assert_eq!(filter.authors, Some(addresses));
|
||||
assert_eq!(filter.kinds, Some(BTreeSet::from([Kind::from(KIND_WRAP)])));
|
||||
}
|
||||
|
||||
fn metadata(name: &str, relay: &str) -> cord02::CommunityMetadata {
|
||||
fn metadata(name: &str) -> cord02::CommunityMetadata {
|
||||
cord02::CommunityMetadata {
|
||||
name: name.to_owned(),
|
||||
relays: vec![relay.to_owned()],
|
||||
..cord02::CommunityMetadata::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn held(id: CommunityId, control_pk: PublicKey) -> CommunityState {
|
||||
CommunityState {
|
||||
id,
|
||||
name: Some("Anime and Manga".to_owned()),
|
||||
owner: Keys::generate().public_key(),
|
||||
owner_salt: [0x01; 32],
|
||||
community_root: [0x02; 32],
|
||||
root_epoch: Epoch(0),
|
||||
control_root: Some([0x03; 32]),
|
||||
control_pks: BTreeMap::from([(0, control_pk)]),
|
||||
channels: vec![concord::store::ChannelKeyRef {
|
||||
id: ChannelId::from_bytes([0x9c; 32]),
|
||||
name: "general".to_owned(),
|
||||
private: false,
|
||||
epoch: Epoch(0),
|
||||
key: None,
|
||||
}],
|
||||
relays: Vec::new(),
|
||||
heads: Vec::new(),
|
||||
banned: BTreeSet::new(),
|
||||
dissolved: false,
|
||||
added_at_ms: 1_700_000_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts a List in the database as the account's own fragment, exactly as a
|
||||
/// relay would have delivered it.
|
||||
async fn store_fragment<S>(client: &Client, signer: &S, list: &CommunityList)
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let event = cord02::list::build_list_event(signer, list, 0, 1_700_000_000)
|
||||
.await
|
||||
.expect("builds");
|
||||
client.database().save_event(&event).await.expect("saves");
|
||||
}
|
||||
|
||||
/// What `CommunityRegistry` needs from a created community: a state document
|
||||
/// `load` finds, a control plane the subscription filter actually addresses,
|
||||
/// and a fold that survives an inbound control edit.
|
||||
@@ -490,7 +638,7 @@ mod tests {
|
||||
let keys = Keys::generate();
|
||||
let signer = UniversalSigner::new(keys.clone());
|
||||
|
||||
let created = create(&client, &signer, &metadata("coop", "wss://relay.example"))
|
||||
let created = create(&client, &signer, &metadata("coop"))
|
||||
.await
|
||||
.expect("creates");
|
||||
|
||||
@@ -547,7 +695,7 @@ mod tests {
|
||||
.set_community_metadata(
|
||||
&keys,
|
||||
&created.id,
|
||||
&metadata("coop two", "wss://relay.example"),
|
||||
&metadata("coop two"),
|
||||
Some(community_head),
|
||||
None,
|
||||
Timestamp::now().as_secs() + 1,
|
||||
@@ -570,4 +718,194 @@ mod tests {
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// The other half of `create`: the membership must reach the account's
|
||||
/// Community List, and a later create must union into it rather than replace
|
||||
/// it (CORD-02 §8 read-modify-write).
|
||||
#[test]
|
||||
fn creating_a_community_records_the_membership_in_the_list() {
|
||||
smol::block_on(async {
|
||||
let client = client();
|
||||
let keys = Keys::generate();
|
||||
let signer = UniversalSigner::new(keys.clone());
|
||||
let self_pk = keys.public_key();
|
||||
|
||||
let created = create(&client, &signer, &metadata("coop"))
|
||||
.await
|
||||
.expect("creates");
|
||||
|
||||
let list = load_list(&client, &signer, self_pk)
|
||||
.await
|
||||
.expect("reads")
|
||||
.expect("a list");
|
||||
|
||||
assert_eq!(list.frags, 1);
|
||||
assert!(list.is_complete([0]), "the whole list is one fragment");
|
||||
assert!(list.is_live(&created.id));
|
||||
|
||||
let entry = list
|
||||
.entries
|
||||
.iter()
|
||||
.find(|entry| entry.community_id == created.id)
|
||||
.expect("the membership");
|
||||
assert_eq!(
|
||||
entry.seed, entry.current,
|
||||
"a fresh membership has one anchor"
|
||||
);
|
||||
assert_eq!(entry.current.name, "coop");
|
||||
assert_eq!(entry.current.owner, created.owner);
|
||||
assert_eq!(entry.current.root_epoch, created.root_epoch);
|
||||
assert_eq!(
|
||||
entry.current.control_pk,
|
||||
created.control_pks.get(&0).copied()
|
||||
);
|
||||
assert!(
|
||||
entry.current.control_root.is_some(),
|
||||
"the owner holds the control root"
|
||||
);
|
||||
assert_eq!(entry.current.channels.len(), created.channels.len());
|
||||
assert_eq!(entry.added_at, created.added_at_ms);
|
||||
|
||||
// A second create unions into the same document: the first
|
||||
// membership survives and both are live.
|
||||
let second = create(&client, &signer, &metadata("second"))
|
||||
.await
|
||||
.expect("creates");
|
||||
|
||||
let grown = load_list(&client, &signer, self_pk)
|
||||
.await
|
||||
.expect("reads")
|
||||
.expect("a list");
|
||||
|
||||
assert!(grown.is_live(&created.id));
|
||||
assert!(grown.is_live(&second.id));
|
||||
assert_eq!(grown.entries.len(), 2);
|
||||
});
|
||||
}
|
||||
|
||||
/// The discovery fix: a membership the List carries is materialized even when
|
||||
/// no state document has ever been written for it.
|
||||
#[test]
|
||||
fn load_materializes_a_membership_the_list_carries_with_no_state_document() {
|
||||
smol::block_on(async {
|
||||
let client = client();
|
||||
let keys = Keys::generate();
|
||||
let signer = UniversalSigner::new(keys.clone());
|
||||
|
||||
let listed = held(
|
||||
CommunityId::from_bytes([0x42; 32]),
|
||||
Keys::generate().public_key(),
|
||||
);
|
||||
let list = CommunityList::default().joined(store::list_entry(&listed, "coop"));
|
||||
store_fragment(&client, &signer, &list).await;
|
||||
|
||||
assert!(
|
||||
store::load_state(&client, &listed.id)
|
||||
.await
|
||||
.expect("reads")
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let loaded = load(&client, &signer, keys.public_key())
|
||||
.await
|
||||
.expect("loads");
|
||||
let materialized = loaded
|
||||
.iter()
|
||||
.find(|state| state.id == listed.id)
|
||||
.expect("the list materializes the community");
|
||||
|
||||
assert_eq!(materialized.owner, listed.owner);
|
||||
assert_eq!(materialized.community_root, listed.community_root);
|
||||
assert_eq!(materialized.control_root, listed.control_root);
|
||||
assert_eq!(materialized.control_pks, listed.control_pks);
|
||||
assert_eq!(materialized.channels, listed.channels);
|
||||
assert_eq!(materialized.added_at_ms, listed.added_at_ms);
|
||||
|
||||
// Discovery writes the document, so the next load is warm.
|
||||
assert_eq!(
|
||||
store::load_state(&client, &listed.id)
|
||||
.await
|
||||
.expect("reads")
|
||||
.map(|state| state.id),
|
||||
Some(listed.id)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Absence from the List is never a fact (§8): a held membership the List
|
||||
/// does not mention survives alongside the one it does.
|
||||
#[test]
|
||||
fn load_keeps_a_local_membership_the_list_does_not_mention() {
|
||||
smol::block_on(async {
|
||||
let client = client();
|
||||
let keys = Keys::generate();
|
||||
let signer = UniversalSigner::new(keys.clone());
|
||||
|
||||
let listed = held(
|
||||
CommunityId::from_bytes([0x42; 32]),
|
||||
Keys::generate().public_key(),
|
||||
);
|
||||
let local = held(
|
||||
CommunityId::from_bytes([0x43; 32]),
|
||||
Keys::generate().public_key(),
|
||||
);
|
||||
|
||||
let list = CommunityList::default().joined(store::list_entry(&listed, "listed"));
|
||||
store_fragment(&client, &signer, &list).await;
|
||||
store::save_state(&client, &local).await.expect("saves");
|
||||
|
||||
let loaded = load(&client, &signer, keys.public_key())
|
||||
.await
|
||||
.expect("loads");
|
||||
let ids: BTreeSet<CommunityId> = loaded.iter().map(|state| state.id).collect();
|
||||
|
||||
assert!(ids.contains(&listed.id));
|
||||
assert!(ids.contains(&local.id));
|
||||
});
|
||||
}
|
||||
|
||||
/// Only a tombstone subtracts a membership (§8).
|
||||
#[test]
|
||||
fn a_tombstone_drops_a_held_membership() {
|
||||
smol::block_on(async {
|
||||
let client = client();
|
||||
let keys = Keys::generate();
|
||||
let signer = UniversalSigner::new(keys.clone());
|
||||
|
||||
let local = held(
|
||||
CommunityId::from_bytes([0x42; 32]),
|
||||
Keys::generate().public_key(),
|
||||
);
|
||||
store::save_state(&client, &local).await.expect("saves");
|
||||
|
||||
let list = CommunityList::default().tombstoned(local.id, u64::MAX);
|
||||
store_fragment(&client, &signer, &list).await;
|
||||
|
||||
let loaded = load(&client, &signer, keys.public_key())
|
||||
.await
|
||||
.expect("loads");
|
||||
|
||||
assert!(loaded.iter().all(|state| state.id != local.id));
|
||||
});
|
||||
}
|
||||
|
||||
/// The `concord/list` id must not be read as a community's subscription, or
|
||||
/// every list event would refresh a community instead of triggering `load`.
|
||||
#[test]
|
||||
fn the_list_subscription_is_not_read_as_a_community_subscription() {
|
||||
let id = CommunityId::from_bytes([0x42; 32]);
|
||||
|
||||
assert!(is_list_subscription(&list_subscription_id()));
|
||||
assert!(community_of(&list_subscription_id()).is_none());
|
||||
assert_eq!(community_of(&subscription_id(&id)), Some(id));
|
||||
}
|
||||
|
||||
/// A relay answers a longer REQ with `invalid subscription id length` but
|
||||
/// `subscribe` still reports success, so an over-long id fails silently.
|
||||
#[test]
|
||||
fn a_community_subscription_id_fits_the_nip01_cap() {
|
||||
let id = CommunityId::from_bytes([0x42; 32]);
|
||||
|
||||
assert!(subscription_id(&id).as_str().len() <= 64);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,30 @@ impl CommunityList {
|
||||
(0..self.frags).all(|index| held.contains(&index))
|
||||
}
|
||||
|
||||
pub fn joined(&self, entry: CommunityListEntry) -> CommunityList {
|
||||
merge(
|
||||
self.clone(),
|
||||
CommunityList {
|
||||
entries: vec![entry],
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn tombstoned(&self, community_id: CommunityId, removed_at: u64) -> CommunityList {
|
||||
merge(
|
||||
self.clone(),
|
||||
CommunityList {
|
||||
tombstones: vec![Tombstone {
|
||||
community_id,
|
||||
removed_at,
|
||||
extra: Extra::default(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn fits(&self) -> Result<(), ListError> {
|
||||
if self.entries.len() > MAX_MEMBERSHIPS {
|
||||
return Err(ListError::TooManyMemberships(self.entries.len()));
|
||||
@@ -207,6 +231,7 @@ pub async fn build_list_event<S>(
|
||||
signer: &S,
|
||||
list: &CommunityList,
|
||||
fragment: u64,
|
||||
at_secs: u64,
|
||||
) -> Result<Event, ListError>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
@@ -218,6 +243,7 @@ where
|
||||
|
||||
EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), content)
|
||||
.tag(Tag::identifier(fragment.to_string()))
|
||||
.custom_created_at(Timestamp::from_secs(at_secs))
|
||||
.finalize_async(signer)
|
||||
.await
|
||||
.map_err(crypto_error)
|
||||
@@ -816,7 +842,7 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let event = smol::block_on(build_list_event(&me, &mine, 1)).expect("builds");
|
||||
let event = smol::block_on(build_list_event(&me, &mine, 1, AT_SECS)).expect("builds");
|
||||
assert_eq!(event.kind, Kind::Custom(KIND_COMMUNITY_LIST));
|
||||
assert_eq!(fragment_index(&event).expect("a fragment index"), 1);
|
||||
assert_eq!(
|
||||
@@ -858,7 +884,7 @@ mod tests {
|
||||
.insert("read_key".to_owned(), serde_json::json!("aa".repeat(32)));
|
||||
let rebuilt = smol::block_on(parse_list_event(
|
||||
&me,
|
||||
&smol::block_on(build_list_event(&me, &held, 0)).expect("builds"),
|
||||
&smol::block_on(build_list_event(&me, &held, 0, AT_SECS)).expect("builds"),
|
||||
))
|
||||
.expect("parses");
|
||||
assert_eq!(rebuilt, held);
|
||||
@@ -878,7 +904,7 @@ mod tests {
|
||||
.collect(),
|
||||
);
|
||||
assert!(matches!(
|
||||
smol::block_on(build_list_event(&me, &crowded, 0)),
|
||||
smol::block_on(build_list_event(&me, &crowded, 0, AT_SECS)),
|
||||
Err(ListError::TooManyMemberships(n)) if n == MAX_MEMBERSHIPS + 1
|
||||
));
|
||||
|
||||
@@ -1019,4 +1045,5 @@ mod tests {
|
||||
}
|
||||
|
||||
const AT: u64 = 1_719_800_000_000;
|
||||
const AT_SECS: u64 = AT / 1000;
|
||||
}
|
||||
|
||||
@@ -3,18 +3,20 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use data_encoding::HEXLOWER;
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cord01::{KIND_WRAP_EPHEMERAL, OpenedStream};
|
||||
use crate::cord02::list::JoinMaterial;
|
||||
use crate::cord02::list::{CommunityListEntry, JoinMaterial};
|
||||
use crate::cord02::{
|
||||
ChannelMetadata, CommunityGenesis, CommunityMetadata, ControlFold, ROOT_EPOCH,
|
||||
};
|
||||
use crate::cord03::{self, ChatRumor, plane_keys};
|
||||
use crate::cord04::{EntityHead, Floors, ParsedEdition, vsk};
|
||||
use crate::cord05::ChannelGrant;
|
||||
use crate::derive::control_signer_group_key;
|
||||
use crate::{ChannelId, CommunityId, Epoch, GroupKey, decode_hex_32};
|
||||
use crate::{ChannelId, CommunityId, Epoch, Extra, GroupKey, decode_hex_32};
|
||||
|
||||
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
||||
|
||||
@@ -152,6 +154,8 @@ pub struct ChannelKeyRef {
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CommunityState {
|
||||
pub id: CommunityId,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
pub owner: PublicKey,
|
||||
pub owner_salt: [u8; 32],
|
||||
pub community_root: [u8; 32],
|
||||
@@ -181,6 +185,7 @@ impl CommunityState {
|
||||
let mut channels = Vec::new();
|
||||
let mut heads = Vec::with_capacity(editions.len());
|
||||
let mut relays = Vec::new();
|
||||
let mut name = None;
|
||||
|
||||
for edition in editions {
|
||||
heads.push(EntityHead {
|
||||
@@ -199,6 +204,7 @@ impl CommunityState {
|
||||
.iter()
|
||||
.filter_map(|relay| RelayUrl::parse(relay).ok()),
|
||||
);
|
||||
name = label(&metadata.name);
|
||||
}
|
||||
vsk::CHANNEL_METADATA => {
|
||||
let metadata: ChannelMetadata = serde_json::from_str(&edition.content)?;
|
||||
@@ -226,6 +232,7 @@ impl CommunityState {
|
||||
|
||||
Ok(Self {
|
||||
id: genesis.identity.community_id,
|
||||
name,
|
||||
owner: genesis.identity.owner,
|
||||
owner_salt: genesis.identity.owner_salt,
|
||||
community_root: genesis.community_root,
|
||||
@@ -265,6 +272,7 @@ impl CommunityState {
|
||||
|
||||
Ok(Self {
|
||||
id: material.community_id,
|
||||
name: label(&material.name),
|
||||
owner: material.owner,
|
||||
owner_salt: decode_hex_32(&material.owner_salt)?,
|
||||
community_root: decode_hex_32(&material.community_root)?,
|
||||
@@ -308,6 +316,10 @@ impl CommunityState {
|
||||
.iter()
|
||||
.filter_map(|relay| RelayUrl::parse(relay).ok())
|
||||
.collect();
|
||||
|
||||
if let Some(name) = label(&community.name) {
|
||||
self.name = Some(name);
|
||||
}
|
||||
}
|
||||
|
||||
for (id, metadata) in &fold.channels {
|
||||
@@ -337,6 +349,45 @@ impl CommunityState {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_entry(state: &CommunityState, name: &str) -> CommunityListEntry {
|
||||
let material = JoinMaterial {
|
||||
community_id: state.id,
|
||||
owner: state.owner,
|
||||
owner_salt: HEXLOWER.encode(&state.owner_salt),
|
||||
community_root: HEXLOWER.encode(&state.community_root),
|
||||
root_epoch: state.root_epoch,
|
||||
control_pk: state.control_pks.get(&state.root_epoch.0).copied(),
|
||||
control_root: state.control_root.map(|root| HEXLOWER.encode(&root)),
|
||||
channels: state
|
||||
.channels
|
||||
.iter()
|
||||
.map(|channel| ChannelGrant {
|
||||
id: channel.id,
|
||||
key: channel.key.map(|key| HEXLOWER.encode(&key)),
|
||||
epoch: channel.epoch,
|
||||
name: channel.name.clone(),
|
||||
extra: Extra::default(),
|
||||
})
|
||||
.collect(),
|
||||
relays: state.relays.iter().map(RelayUrl::to_string).collect(),
|
||||
name: name.to_owned(),
|
||||
extra: Extra::default(),
|
||||
};
|
||||
|
||||
CommunityListEntry {
|
||||
community_id: state.id,
|
||||
seed: material.clone(),
|
||||
current: material,
|
||||
added_at: state.added_at_ms,
|
||||
extra: Extra::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn label(name: &str) -> Option<String> {
|
||||
let trimmed = name.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_owned())
|
||||
}
|
||||
|
||||
fn state_identifier(id: &CommunityId) -> String {
|
||||
format!("{STATE_PREFIX}{}", id.to_hex())
|
||||
}
|
||||
@@ -674,6 +725,7 @@ mod tests {
|
||||
|
||||
let state = CommunityState {
|
||||
id: CommunityId::from_bytes([0x42; 32]),
|
||||
name: Some("Anime and Manga".to_owned()),
|
||||
owner: Keys::generate().public_key(),
|
||||
owner_salt: [0x01; 32],
|
||||
community_root: [0x02; 32],
|
||||
|
||||
@@ -318,6 +318,63 @@ pub async fn download_and_decrypt_to_file(
|
||||
Err(anyhow!("File download not supported on web"))
|
||||
}
|
||||
|
||||
/// The cache file a decrypted blob for `plaintext_sha256` is written to.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn blob_cache_path(plaintext_sha256: &str) -> PathBuf {
|
||||
std::env::temp_dir()
|
||||
.join("coop-blobs")
|
||||
.join(plaintext_sha256)
|
||||
}
|
||||
|
||||
/// Download an encrypted blob whose pointer carries the *plaintext* hash
|
||||
/// and write the decrypted bytes to a content-addressed cache file,
|
||||
/// so later renders skip the network.
|
||||
///
|
||||
/// The cache file carries no extension: `img` sniffs the format from the bytes.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn download_and_decrypt_to_cache(
|
||||
url: &Url,
|
||||
key: &str,
|
||||
nonce: &str,
|
||||
plaintext_sha256: &str,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<PathBuf, Error> {
|
||||
let path = blob_cache_path(plaintext_sha256);
|
||||
|
||||
if smol::fs::metadata(&path).await.is_ok() {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
let data = download_and_decrypt(url, key, nonce, None, cx).await?;
|
||||
|
||||
if !sha256_hex(&data).eq_ignore_ascii_case(plaintext_sha256) {
|
||||
bail!("Blob hash mismatch");
|
||||
}
|
||||
|
||||
let Some(parent) = path.parent() else {
|
||||
bail!("Invalid blob cache path");
|
||||
};
|
||||
smol::fs::create_dir_all(parent).await?;
|
||||
|
||||
// Write under a temporary name first, so an interrupted download is never reused
|
||||
let partial = path.with_extension("download");
|
||||
smol::fs::write(&partial, data).await?;
|
||||
smol::fs::rename(&partial, &path).await?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn download_and_decrypt_to_cache(
|
||||
_url: &Url,
|
||||
_key: &str,
|
||||
_nonce: &str,
|
||||
_plaintext_sha256: &str,
|
||||
_cx: &AsyncApp,
|
||||
) -> Result<PathBuf, Error> {
|
||||
Err(anyhow!("Blob download not supported on web"))
|
||||
}
|
||||
|
||||
fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
|
||||
tags.iter()
|
||||
.find(|tag| tag.kind() == name)
|
||||
|
||||
+15
-6
@@ -1,8 +1,8 @@
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AbsoluteLength, AnyElement, App, Bounds, Div, Hsla, InteractiveElement, Interactivity,
|
||||
IntoElement, ObjectFit, ParentElement, PathBuilder, Pixels, Point, RenderOnce, SharedString,
|
||||
StyleRefinement, Styled, StyledImage, Window, canvas, div, img, point, px,
|
||||
AbsoluteLength, AnyElement, App, Bounds, Div, Hsla, ImageSource, InteractiveElement,
|
||||
Interactivity, IntoElement, ObjectFit, ParentElement, PathBuilder, Pixels, Point, RenderOnce,
|
||||
SharedString, StyleRefinement, Styled, StyledImage, Window, canvas, div, img, point, px,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
@@ -373,7 +373,7 @@ fn generated_avatar(seed: Option<&str>, size: Pixels) -> AnyElement {
|
||||
#[derive(IntoElement)]
|
||||
pub struct Avatar {
|
||||
base: Div,
|
||||
picture: Option<SharedString>,
|
||||
picture: Option<ImageSource>,
|
||||
grayscale: bool,
|
||||
seed: Option<SharedString>,
|
||||
style: StyleRefinement,
|
||||
@@ -385,9 +385,18 @@ pub struct Avatar {
|
||||
impl Avatar {
|
||||
/// Creates an avatar for an entity whose profile picture may be missing.
|
||||
///
|
||||
/// Use [`Avatar::seed`] to choose the generated pixel avatar rendered when
|
||||
/// `picture` is `None`.
|
||||
/// Use [`Avatar::seed`] to choose the generated
|
||||
/// pixel avatar rendered when `picture` is `None`.
|
||||
pub fn new(picture: Option<SharedString>) -> Self {
|
||||
Self::from_picture(picture.map(ImageSource::from))
|
||||
}
|
||||
|
||||
/// Creates an avatar from an already-resolved source.
|
||||
pub fn from_source(picture: impl Into<ImageSource>) -> Self {
|
||||
Self::from_picture(Some(picture.into()))
|
||||
}
|
||||
|
||||
fn from_picture(picture: Option<ImageSource>) -> Self {
|
||||
Avatar {
|
||||
base: div(),
|
||||
picture,
|
||||
|
||||
@@ -404,6 +404,7 @@ impl Sidebar {
|
||||
)
|
||||
.depth(*depth)
|
||||
.avatar(community.id().to_hex())
|
||||
.picture(community.icon())
|
||||
.into_any_element()
|
||||
}
|
||||
SidebarRow::NewCommunity { depth } => TreeRow::new(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::path::PathBuf;
|
||||
use std::rc::Rc;
|
||||
|
||||
use chat::Room;
|
||||
@@ -8,7 +9,7 @@ use gpui::{
|
||||
SharedString, StatefulInteractiveElement, Styled, Window, div, px,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::PixelAvatar;
|
||||
use ui::avatar::{Avatar, PixelAvatar};
|
||||
use ui::{Icon, IconName, Sizable, StyledExt, h_flex};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
@@ -94,6 +95,7 @@ pub struct TreeRow {
|
||||
caret: Option<IconName>,
|
||||
icon: Option<IconName>,
|
||||
avatar: Option<SharedString>,
|
||||
picture: Option<PathBuf>,
|
||||
label: SharedString,
|
||||
count: Option<usize>,
|
||||
dot: bool,
|
||||
@@ -114,6 +116,7 @@ impl TreeRow {
|
||||
caret: None,
|
||||
icon: None,
|
||||
avatar: None,
|
||||
picture: None,
|
||||
label: label.into(),
|
||||
count: None,
|
||||
dot: false,
|
||||
@@ -142,6 +145,12 @@ impl TreeRow {
|
||||
self
|
||||
}
|
||||
|
||||
/// Shows `picture` instead of the generated avatar.
|
||||
pub fn picture(mut self, picture: Option<PathBuf>) -> Self {
|
||||
self.picture = picture;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn count(mut self, count: usize) -> Self {
|
||||
self.count = Some(count);
|
||||
self
|
||||
@@ -164,11 +173,21 @@ impl TreeRow {
|
||||
impl RenderOnce for TreeRow {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let indent = px(6. + self.depth as f32 * 14.);
|
||||
let avatar_seed = self.avatar;
|
||||
let is_section = self.kind == TreeRowKind::Section;
|
||||
let is_community = self.kind == TreeRowKind::Community;
|
||||
let is_hint = self.kind == TreeRowKind::Hint;
|
||||
|
||||
let avatar = match (self.avatar, self.picture) {
|
||||
(seed, Some(picture)) => Some(
|
||||
Avatar::from_source(picture)
|
||||
.when_some(seed, |avatar, seed| avatar.seed(seed))
|
||||
.xsmall()
|
||||
.into_any_element(),
|
||||
),
|
||||
(Some(seed), None) => Some(PixelAvatar::new(seed).xsmall().into_any_element()),
|
||||
(None, None) => None,
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.id(self.id)
|
||||
.h_8()
|
||||
@@ -189,9 +208,7 @@ impl RenderOnce for TreeRow {
|
||||
.when_some(self.icon, |this, icon| {
|
||||
this.child(Icon::new(icon).small().text_color(cx.theme().icon_muted))
|
||||
})
|
||||
.when_some(avatar_seed, |this, seed| {
|
||||
this.child(PixelAvatar::new(seed).xsmall())
|
||||
})
|
||||
.when_some(avatar, |this, avatar| this.child(avatar))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
|
||||
@@ -84,19 +84,18 @@ Two consequences for coop:
|
||||
|
||||
| # | Spec | coop today |
|
||||
| --- | --- | --- |
|
||||
| 1 | kind `33302`, addressable | `cord02::list::KIND_COMMUNITY_LIST = 13302` (retired) |
|
||||
| 2 | one event per fragment, `d` = index, `frags` declared | no `frags`, single event, `d` unused, `load_list` `.limit(1)` |
|
||||
| 3 | 32-byte values unpadded base64url at any depth | hex: `JoinMaterial.owner`/`control_root` (`PublicKey`/`String`), `CommunityId` serde, `ChannelGrant.key` |
|
||||
| 4 | `seed` omitted when equal to `current`; embedded snapshot omits `community_id`; `seed`'s cosmetic fields rewritten from `current` | both snapshots always serialized verbatim; `community_id` always present |
|
||||
| 1 | kind `33302`, addressable | was `cord02::list::KIND_COMMUNITY_LIST = 13302` (retired) — **fixed in Phase A** |
|
||||
| 2 | one event per fragment, `d` = index, `frags` declared | was no `frags`, single event, `d` unused, `load_list` `.limit(1)` — **fixed in Phase A** |
|
||||
| 3 | 32-byte values unpadded base64url at any depth | was hex for `JoinMaterial.owner`/`control_root`, `CommunityId` serde, `ChannelGrant.key` — **fixed in Phase A** |
|
||||
| 4 | `seed` omitted when equal to `current`; embedded snapshot omits `community_id`; `seed`'s cosmetic fields rewritten from `current` | was both snapshots emitted verbatim, `community_id` always present — **fixed in Phase A** |
|
||||
| 5 | fetch from relays | local database only — **fixed in Phase C** |
|
||||
| 6 | materialize `CommunityState` from join material | no such path; only `CommunityState::from_genesis` |
|
||||
| 7 | publish the List on create/join (read-modify-write) | `build_list_event` is referenced only by tests and docs |
|
||||
| 8 | private channel keys ride in join material | `ChannelKeyRef` has no key field |
|
||||
| 6 | materialize `CommunityState` from join material | was no such path; only `CommunityState::from_genesis` — **fixed in Phase B** |
|
||||
| 7 | publish the List on create/join (read-modify-write) | `build_list_event` is referenced only by tests and docs — **fixed in Phase D** |
|
||||
| 8 | private channel keys ride in join material | `ChannelKeyRef` has a key field, but private planes are still not subscribed |
|
||||
|
||||
Divergences 1–4 meant that even if the fetch existed, coop could neither read
|
||||
what accordion wrote nor write something accordion could read. **Phases A, B and
|
||||
C are done**, so 1–6 are resolved; 7 and 8 remain (8 only in that private planes
|
||||
are still not subscribed).
|
||||
Divergences 1–7 are resolved. 8 remains, in the narrow sense that `planes()`
|
||||
still skips private channels rather than deriving their addresses from the
|
||||
granted key.
|
||||
|
||||
## Plan
|
||||
|
||||
@@ -140,9 +139,9 @@ example has five such values, so a strict decoder rejects the worked example;
|
||||
like the other two, so it belongs here rather than in Phase D.
|
||||
|
||||
`parse_list_event` validates the `d` tag but returns just the `CommunityList`;
|
||||
`fragment_index(event)` reads the index, which keeps `sync.rs` untouched until
|
||||
Phase C. `MAX_MEMBERSHIPS = 50` is kept for now as a stopgap (see risks): §8 has
|
||||
no membership limit, and Phase D's fragmentation is what removes the cap.
|
||||
`fragment_index(event)` reads the index, which kept `sync.rs` untouched until
|
||||
Phase C. `MAX_MEMBERSHIPS = 50` is kept as a stopgap (see risks): §8 has no
|
||||
membership limit, and removing the cap needs write-time fragmentation.
|
||||
|
||||
### Phase B — materialize a community from join material (pure) — DONE
|
||||
|
||||
@@ -221,27 +220,53 @@ needs no explicit save. The subscription is set up with `ReqTarget::auto` rather
|
||||
than a hand-built NIP-65 relay map, because gossip already resolves the author's
|
||||
write relays and connects them on demand.
|
||||
|
||||
Tests (no network): a fragment in the database with **no** state document
|
||||
materializes a community and writes one; a tombstone at `u64::MAX` drops a held
|
||||
membership; a two-fragment List with only fragment 0 delivered still yields its
|
||||
membership; a held membership the List never mentions is kept alongside the
|
||||
discovered one; `refresh` keeps `heads`/`banned`/`dissolved` and both control
|
||||
planes while taking the List's keys; and the `concord/list` id is not read as a
|
||||
community subscription. Fragment events are built with `build_list_event` from a
|
||||
§8 JSON payload, so the test exercises the real decrypt-and-merge path without a
|
||||
relay.
|
||||
Tests (no network, in `crates/community/src/sync.rs`): a membership the List
|
||||
carries materializes a community even though no state document was ever written
|
||||
for it, and discovery writes the document so the next load is warm; a held
|
||||
membership the List never mentions is kept alongside the one it does; a tombstone
|
||||
outranks a held membership and drops it; and the `concord/list` id is not read as
|
||||
a community subscription. Fragment events are built with `store::list_entry` +
|
||||
`CommunityList::joined` + `build_list_event` and saved straight into a memory
|
||||
database, so the tests exercise the real seal/parse/merge path without a relay.
|
||||
|
||||
### Phase D — publish
|
||||
### Phase D — publish — DONE
|
||||
|
||||
`crates/community/src/sync.rs`, `crates/concord/src/store.rs`
|
||||
`crates/community/src/sync.rs`, `crates/concord/src/store.rs`,
|
||||
`crates/concord/src/cords/cord02/list.rs`
|
||||
|
||||
1. `create` appends to the List and publishes the fragment read-modify-write per
|
||||
§8, targeting the metadata's relays.
|
||||
2. `create` publishes the genesis wraps to those relays. Today it only
|
||||
`client.database().save_event(wrap)`s, so a created community is invisible to
|
||||
every other account.
|
||||
3. Leave uses a tombstone; a repack requires the complete List and is a
|
||||
non-goal until memberships outgrow one fragment.
|
||||
1. `create` mints the genesis, folds it into a state, and saves that state locally
|
||||
as before, then announces the community: the genesis wraps to its relay set,
|
||||
and the membership to the account's own List. Both publishes are best-effort —
|
||||
a relay that is down is a warning, not a failed create.
|
||||
2. The List write is a read-modify-write over the copy already held (§8). `create`
|
||||
reads the newest held fragment, unions its own entry in with
|
||||
`CommunityList::joined`, builds fragment 0, and publishes it. Publishing saves
|
||||
it locally as a side effect of `send_event`, before any relay is resolved, so
|
||||
the fragment survives a relay that is down and no explicit database write is
|
||||
needed.
|
||||
3. The fragment's `created_at` is `max(now, previous + 1)`, so an addressable
|
||||
relay can never quietly keep the copy the write meant to replace.
|
||||
|
||||
**As built, deviating from the sketch above.** Three decisions the sketch did not
|
||||
cover:
|
||||
|
||||
- The List goes to the account's **NIP-65 write relays** (`.to_nip65()`), not the
|
||||
community's metadata relays. The List is the member's own document, and it is
|
||||
the same relay set `subscribe_list` resolves for its `author` filter — the two
|
||||
halves must agree or a write can land where nothing reads. The genesis wraps,
|
||||
which belong to the community and not the member, do go to the metadata relays.
|
||||
- The entry is built by a new `store::list_entry(state, name)`. `JoinMaterial`'
|
||||
`extra` field is crate-private, so the community crate cannot build one; `name`
|
||||
is passed in because the state does not carry it — the name lives in the Control
|
||||
fold, and a created community has it in the metadata.
|
||||
- A List that already spans more than one fragment is **left alone**: placing a
|
||||
new membership needs a repack (which fragment does it belong in?), and §8 allows
|
||||
a repack only against the complete List. `load` keeps a membership the List
|
||||
never mentions, so the community is still tracked locally; the remote write is
|
||||
deferred with a warning rather than performed wrongly.
|
||||
|
||||
Tests: `create` records a membership the List round-trips, and a second create
|
||||
unions into the same document instead of replacing it.
|
||||
|
||||
### Phase E — verify live
|
||||
|
||||
@@ -267,9 +292,11 @@ rows in the sidebar. This is the first time the path can be exercised at all.
|
||||
`list.rs` and never case-folds.
|
||||
- **`MAX_MEMBERSHIPS = 50` is not in the spec.** §8 has no membership limit; its
|
||||
only bound is the 65,536-byte *encoded event*. `fits()` still measures the
|
||||
NIP-44 plaintext, which understates that by roughly a third, so the count cap is
|
||||
kept as a conservative stopgap until Phase D measures the built event and
|
||||
fragments on write.
|
||||
NIP-44 plaintext, which understates that by roughly a third. Phase D kept the
|
||||
count cap and added a guard: a List that already spans more than one fragment is
|
||||
not appended to, because placing a new membership needs a repack. So a member
|
||||
with more than one fragment gets no remote write until fragmentation lands; the
|
||||
community stays local and visible.
|
||||
- **Relay selection is the difference between finding the account's List and
|
||||
not.** Resolved in Phase C by `ReqTarget::auto`, whose gossip path resolves the
|
||||
filter's author to their NIP-65 write relays and connects them. A List
|
||||
@@ -282,6 +309,28 @@ rows in the sidebar. This is the first time the path can be exercised at all.
|
||||
both accordion and coop has both clients writing the List. §8's
|
||||
read-modify-write is what keeps that from losing memberships — it is not
|
||||
optional.
|
||||
- **A create racing the first list sync can publish over an unseen List.**
|
||||
`record_membership` unions into what the local database holds, and on a fresh
|
||||
sign-in that is empty until the `concord/list` subscription has delivered. A
|
||||
create in that window writes a one-entry fragment 0, and an addressable relay
|
||||
then replaces the account's fuller List with it. The window is the ordinary
|
||||
sign-in-to-create interval, so it is small but not zero. The honest fix is to
|
||||
treat the List write as part of the sync loop — republish `list ∪ local
|
||||
memberships` whenever the subscription settles — rather than doing it inside
|
||||
`create`; an EOSE flag is not enough on its own, because an account with no
|
||||
NIP-65 relays never reaches EOSE and would then never write at all.
|
||||
- **`store::save_state` signs with a per-process random key.** Harmless while it
|
||||
stays local, but it means the state document can never be published or
|
||||
compared; if a future phase wants it on the wire, it needs the account signer.
|
||||
- **The deployed reference client still writes the retired kind `13302`.** The
|
||||
spec this plan implements (`concord-protocol/concord` `main`) moved the List to
|
||||
`33302` in PR #18, merged **2026-08-15**. The `applesauce` `concord` branch that
|
||||
accordion.chat builds against still declares `13302`, single-event, capped at 50
|
||||
memberships, at its head of **2026-08-05**; accordion's pin predates even that
|
||||
(`0.0.0-concord-20260804145327`). So an account whose memberships were written
|
||||
by that build stores them under a kind coop deliberately does not read, and will
|
||||
show an empty sidebar until the client is updated to the fragmented kind. This
|
||||
is not a bug in the discovery path — Phases C and D are correct against the
|
||||
current spec — but it is the first thing to check if a live sign-in still shows
|
||||
nothing. Supporting `13302` alongside `33302` is a deliberate non-goal until the
|
||||
reference client moves.
|
||||
|
||||
+38
-12
@@ -79,6 +79,12 @@ save_state(&client, &state).await?;
|
||||
Put the community's relay list into `state.relays` and add those relays to the
|
||||
client explicitly — coop's client is a gossip client with no background refresh.
|
||||
|
||||
Creating is not finished until the membership is announced. The two writes are
|
||||
independent and both best-effort: the genesis wraps go to the community's
|
||||
relays, and the membership goes to the account's own Community List (below), so a
|
||||
new device — or another client — can find the community without an invite.
|
||||
`crates/community`'s `sync::create` performs both.
|
||||
|
||||
## Joining
|
||||
|
||||
An invite link resolves to a bundle:
|
||||
@@ -413,15 +419,29 @@ A member's own memberships, synced across their devices:
|
||||
```rust
|
||||
use concord::cord02::list;
|
||||
|
||||
let material = list::join_material(&invite, staff.then_some(&control_root));
|
||||
let mut mine = list::parse_list_event(&my_keys, &event).await?; // validates the d tag
|
||||
mine = list::merge(mine, list::CommunityList {
|
||||
entries: vec![list::CommunityListEntry { community_id, seed: material.clone(), current: material, added_at: now_ms, extra: Default::default() }],
|
||||
..Default::default() // frags: 1
|
||||
});
|
||||
let event = list::build_list_event(&my_keys, &mine, 0).await?; // kind 33302, d = fragment 0
|
||||
let entry = concord::store::list_entry(&state, &metadata.name); // state → §8 material
|
||||
let held = list::parse_list_event(&my_keys, &event).await?; // validates the d tag
|
||||
let mine = held.joined(entry); // community_id-keyed union
|
||||
let event = list::build_list_event(&my_keys, &mine, 0, now_secs).await?; // kind 33302, d = 0
|
||||
client.send_event(&event).to_nip65().await?; // account's own write relays
|
||||
```
|
||||
|
||||
The publish needs no separate database write: `send_event` persists the event
|
||||
locally *before* it resolves targets, so the fragment is available to the
|
||||
`concord/list` read path even if every relay is unreachable.
|
||||
|
||||
`join_material(&invite, control_root)` makes the §8 material from a CORD-05
|
||||
invite; `store::list_entry(state, name)` makes it from a `CommunityState`, and is
|
||||
what a write path uses after a create, join or rename. `joined` and `tombstoned`
|
||||
are the two mutations: both are `community_id`-keyed unions, so neither an append
|
||||
nor a leave can lose a membership the other writer has.
|
||||
|
||||
The entry is signed by the member's real key and sealed to that same key
|
||||
(`seal_to_self`), so only the member's devices read it — a stranger's
|
||||
`parse_list_event` fails rather than returning a partial list. Publishing goes to
|
||||
the member's **NIP-65 write relays**, the same set the `concord/list`
|
||||
subscription resolves for its `author` filter.
|
||||
|
||||
Kind `33302` is **addressable and fragmented**: one event per fragment, its `d`
|
||||
tag the fragment index in decimal. `frags` in the payload declares how many the
|
||||
List has, and `is_complete(held_indices)` answers whether the client has a
|
||||
@@ -452,8 +472,11 @@ and its wire form differ:
|
||||
strictly newer join outruns it. `fits()` is the write gate: 50 memberships and
|
||||
the NIP-44 plaintext cap. The 50 is a stopgap inherited from the retired
|
||||
single-event design — §8 has **no membership limit**, its only bound is the
|
||||
65,536-byte encoded event, and the real fix is to start a new fragment on write
|
||||
(see `docs/concord-community-discovery-plan.md`, Phase D).
|
||||
65,536-byte encoded event, and the real fix is to start a new fragment on write.
|
||||
Until that lands, an append onto a List that already spans more than one fragment
|
||||
is refused rather than performed against a partial read, because placing a new
|
||||
membership needs a repack. The community stays local (`load` keeps a membership
|
||||
the List never mentions) and the write is deferred with a warning.
|
||||
|
||||
Discovery is a **subscription, not a fetch**: subscribe with
|
||||
`Filter::new().kind(Kind::Custom(KIND_COMMUNITY_LIST)).author(my_pk)` and read the
|
||||
@@ -581,8 +604,10 @@ client.subscribe(filter).with_id(sub_id).await?;
|
||||
per state document, subscribes when a community's plane set changes, and
|
||||
re-folds on an inbound wrap. The sidebar observes the registry, logs
|
||||
`CommunityEvent::Error` through `log::error!`, and its "New community" row opens
|
||||
a name prompt that calls `CommunityRegistry::create`. `create` still persists
|
||||
the genesis locally without publishing it to the metadata's relays. Discovery
|
||||
a name prompt that calls `CommunityRegistry::create`. `create` persists the
|
||||
genesis locally, publishes the wraps to the community's relays, and records the
|
||||
membership in the account's Community List — all best-effort, so a relay that is
|
||||
down warns without losing the community. Discovery
|
||||
subscribes to the account's CORD-02 Community List (`33302`) under the
|
||||
`concord/list` subscription id and reads the fragments back out of
|
||||
`client.database()` — the SDK persists a relay's event before notifying, so the
|
||||
@@ -590,7 +615,8 @@ client.subscribe(filter).with_id(sub_id).await?;
|
||||
materializes a community from each live List entry (`from_join_material`) and
|
||||
keeps any state document the List does not mention, so a fresh install — or one
|
||||
signing in as an account that joined elsewhere — finds its communities. See
|
||||
`docs/concord-community-discovery-plan.md`.
|
||||
`docs/concord-community-discovery-plan.md` (including its note on the retired
|
||||
`13302` the current reference client still writes).
|
||||
- **Account-key writers take any signer, not `&Keys`.** `genesis`,
|
||||
`ControlWriter`, the guestbook and chat `seal_rumor`s, the `list` builders, and
|
||||
the `cord05` invite writers (`build_direct_invite` / `unwrap_direct_invite`,
|
||||
|
||||
Reference in New Issue
Block a user