diff --git a/crates/community/src/community.rs b/crates/community/src/community.rs index 8b137891..01d0611c 100644 --- a/crates/community/src/community.rs +++ b/crates/community/src/community.rs @@ -1 +1,229 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; +use anyhow::Result; +use concord::cord02::ControlFold; +use concord::store::{ChannelKeyRef, CommunityState}; +use concord::{ChannelId, CommunityId, Epoch}; +use gpui::{AppContext, Context, EventEmitter, Task}; +use nostr_sdk::prelude::*; + +use crate::sync::{self, Snapshot}; + +/// Everything that decides which planes a community is subscribed to. The +/// registry re-subscribes only when this changes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubscriptionKey { + control_pks: BTreeMap, + channels: Vec<(ChannelId, Epoch, bool)>, + relays: Vec, +} + +impl SubscriptionKey { + fn of(state: &CommunityState) -> Self { + Self { + control_pks: state.control_pks.clone(), + channels: state + .channels + .iter() + .map(|channel| (channel.id, channel.epoch, channel.private)) + .collect(), + relays: state.relays.clone(), + } + } +} + +#[derive(Debug, Clone)] +pub enum CommunityEvent { + Updated(CommunityId), + Error(String), +} + +pub struct Community { + state: CommunityState, + control: ControlFold, + members: BTreeSet, + database: Arc, + dirty: bool, + refresh_task: Option>>, +} + +impl EventEmitter for Community {} + +impl Community { + pub fn new(state: CommunityState, database: Arc) -> Self { + Self { + state, + control: ControlFold::default(), + members: BTreeSet::new(), + database, + dirty: false, + refresh_task: None, + } + } + + pub fn id(&self) -> CommunityId { + self.state.id + } + + pub fn state(&self) -> &CommunityState { + &self.state + } + + pub fn control(&self) -> &ControlFold { + &self.control + } + + pub fn members(&self) -> &BTreeSet { + &self.members + } + + pub fn channels(&self) -> &[ChannelKeyRef] { + &self.state.channels + } + + pub fn subscription_key(&self) -> SubscriptionKey { + SubscriptionKey::of(&self.state) + } + + /// Rebuilds the community from the wraps in the local database. A burst of + /// signals produces at most two folds: one running, one owed. + pub fn refresh(&mut self, cx: &mut Context) { + if self.refresh_task.is_some() { + self.dirty = true; + return; + } + + let database = self.database.clone(); + let state = self.state.clone(); + let folded = + cx.background_spawn(async move { sync::fold(database.as_ref(), &state).await }); + + self.refresh_task = Some(cx.spawn(async move |this, cx| { + let result = folded.await; + this.update(cx, |this, cx| this.apply(result, cx))?; + Ok(()) + })); + } + + fn apply(&mut self, result: Result>, cx: &mut Context) { + self.refresh_task = None; + + match result { + Ok(Some(snapshot)) => { + self.state = snapshot.state; + self.control = snapshot.control; + self.members = snapshot.members; + cx.emit(CommunityEvent::Updated(self.state.id)); + cx.notify(); + } + Ok(None) => {} + Err(error) => cx.emit(CommunityEvent::Error(error.to_string())), + } + + if self.dirty { + self.dirty = false; + self.refresh(cx); + } + } +} + +#[cfg(test)] +mod tests { + use concord::cord02::GENERAL_CHANNEL; + use concord::cord02::guestbook::{build_join, build_leave, seal_rumor}; + use concord::derive::guestbook_group_key; + use concord::store::{load_state, save_state}; + use nostr_memory::MemoryDatabase; + + use super::*; + use crate::sync::fixtures::{AT_MS, community}; + + #[test] + fn genesis_folds_into_metadata_channels_and_the_owner() { + smol::block_on(async { + let owner = Keys::generate(); + let (genesis, state) = community(&owner); + let database = MemoryDatabase::unbounded(); + + for wrap in &genesis.wraps { + database.save_event(wrap).await.expect("saves wrap"); + } + save_state(&database, &state).await.expect("saves state"); + + let snapshot = sync::fold(&database, &state) + .await + .expect("folds") + .expect("genesis is a control edition"); + + let metadata = snapshot.control.community.as_ref().expect("metadata"); + assert_eq!(metadata.name, "Room"); + + let channel = snapshot + .control + .channels + .get(&genesis.channel_id) + .expect("general channel"); + assert_eq!(channel.name, GENERAL_CHANNEL); + assert!(!channel.private); + + assert_eq!(snapshot.members, BTreeSet::from([owner.public_key()])); + + let persisted = load_state(&database, &state.id) + .await + .expect("loads") + .expect("persisted"); + assert_eq!(persisted.id, state.id); + }); + } + + #[test] + fn a_join_adds_a_member_and_a_later_leave_removes_them() { + smol::block_on(async { + let owner = Keys::generate(); + let member = Keys::generate(); + let (genesis, state) = community(&owner); + let database = MemoryDatabase::unbounded(); + + for wrap in &genesis.wraps { + database.save_event(wrap).await.expect("saves wrap"); + } + save_state(&database, &state).await.expect("saves state"); + + let guestbook = guestbook_group_key(&state.community_root, &state.id, state.root_epoch) + .expect("guestbook key"); + + let join = seal_rumor( + &build_join(member.public_key(), None, AT_MS + 1_000), + &guestbook, + &member, + ) + .expect("seals join") + .0; + database.save_event(&join).await.expect("saves join"); + + let joined = sync::fold(&database, &state) + .await + .expect("folds") + .expect("control is still held"); + assert!(joined.members.contains(&member.public_key())); + assert_eq!(joined.members.len(), 2); + + let leave = seal_rumor( + &build_leave(member.public_key(), AT_MS + 2_000), + &guestbook, + &member, + ) + .expect("seals leave") + .0; + database.save_event(&leave).await.expect("saves leave"); + + let left = sync::fold(&database, &joined.state) + .await + .expect("folds") + .expect("control is still held"); + assert!(!left.members.contains(&member.public_key())); + assert_eq!(left.members, BTreeSet::from([owner.public_key()])); + }); + } +} diff --git a/crates/community/src/lib.rs b/crates/community/src/lib.rs index ced1b743..bb08fe8c 100644 --- a/crates/community/src/lib.rs +++ b/crates/community/src/lib.rs @@ -3,6 +3,7 @@ use gpui::{App, Window}; mod community; mod sync; +pub use community::*; pub use sync::*; pub fn init(_window: &mut Window, _cx: &mut App) {} diff --git a/crates/community/src/sync.rs b/crates/community/src/sync.rs index d0e9f43e..78caf61f 100644 --- a/crates/community/src/sync.rs +++ b/crates/community/src/sync.rs @@ -260,24 +260,22 @@ fn observe(observed: &mut BTreeMap, author: PublicKey, at_ms: u6 } #[cfg(test)] -mod tests { - use concord::cord02::list::{CommunityListEntry, JoinMaterial, Tombstone, build_list_event}; - use concord::cord02::{CommunityMetadata, ROOT_EPOCH, genesis, open_edition}; +pub(crate) mod fixtures { + use concord::cord02::{CommunityGenesis, CommunityMetadata, ROOT_EPOCH}; use concord::cord04::ParsedEdition; use concord::derive::control_signer_group_key; - use concord::store::save_state; - use nostr_memory::MemoryDatabase; use super::*; - const AT_MS: u64 = 1_719_800_000_000; + pub const AT_MS: u64 = 1_719_800_000_000; - fn community(owner: &Keys) -> CommunityState { + /// A genesis and the state it folds into, ready for a test database. + pub fn community(owner: &Keys) -> (CommunityGenesis, CommunityState) { let metadata = CommunityMetadata { name: "Room".to_owned(), ..Default::default() }; - let genesis = genesis(owner, &metadata, AT_MS / 1000).expect("genesis"); + let genesis = cord02::genesis(owner, &metadata, AT_MS / 1000).expect("genesis"); let read = control_group_key( &genesis.community_root, &genesis.identity.community_id, @@ -295,11 +293,24 @@ mod tests { let editions: Vec = genesis .wraps .iter() - .map(|wrap| open_edition(wrap, &read, &address, true).expect("opens")) + .map(|wrap| cord02::open_edition(wrap, &read, &address, true).expect("opens")) .collect(); - CommunityState::from_genesis(&genesis, &editions, AT_MS).expect("state") + let state = CommunityState::from_genesis(&genesis, &editions, AT_MS).expect("state"); + + (genesis, state) } +} + +#[cfg(test)] +mod tests { + use concord::cord02::ROOT_EPOCH; + use concord::cord02::list::{CommunityListEntry, JoinMaterial, Tombstone, build_list_event}; + use concord::store::save_state; + use nostr_memory::MemoryDatabase; + + use super::fixtures::{AT_MS, community}; + use super::*; fn material(state: &CommunityState) -> JoinMaterial { JoinMaterial { @@ -332,7 +343,7 @@ mod tests { #[test] fn every_held_plane_routes_by_its_wrap_author() { let owner = Keys::generate(); - let state = community(&owner); + let state = community(&owner).1; let planes = planes(&state).expect("planes"); assert_eq!( @@ -356,7 +367,7 @@ mod tests { let keys = Keys::generate(); let signer = UniversalSigner::new(keys.clone()); let owner = Keys::generate(); - let state = community(&owner); + let state = community(&owner).1; // With no list event, every state document is a community. let no_list = MemoryDatabase::unbounded();