diff --git a/Cargo.lock b/Cargo.lock index fcf6c28d..4d3732cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1274,6 +1274,41 @@ dependencies = [ "regex", ] +[[package]] +name = "community" +version = "1.0.2" +dependencies = [ + "anyhow", + "concord", + "flume 0.11.1", + "futures", + "gpui-pre", + "log", + "nostr-memory", + "nostr-sdk", + "serde_json", + "smallvec", + "smol", + "state", +] + +[[package]] +name = "community_ui" +version = "1.0.2" +dependencies = [ + "anyhow", + "common", + "community", + "gpui-pre", + "nostr-sdk", + "person", + "settings", + "smallvec", + "state", + "theme", + "ui", +] + [[package]] name = "compression-codecs" version = "0.4.43" @@ -1297,12 +1332,12 @@ name = "concord" version = "1.0.2" dependencies = [ "anyhow", + "base64 0.22.1", "chacha20 0.9.1", "data-encoding", "hkdf", "hmac 0.12.1", "nostr", - "nostr-memory", "nostr-sdk", "rand 0.10.2", "serde", @@ -1412,6 +1447,7 @@ dependencies = [ "auto_update", "chat", "common", + "community", "device", "gpui-pre", "gpui-pre-linux", @@ -1437,6 +1473,7 @@ dependencies = [ "assets", "chat", "common", + "community", "console_error_panic_hook", "console_log", "device", @@ -9323,6 +9360,8 @@ dependencies = [ "chat", "chat_ui", "common", + "community", + "community_ui", "device", "gpui-pre", "instant", diff --git a/Cargo.toml b/Cargo.toml index 8ced0833..c7968032 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "ni # Crypto (NIP-17 encrypted file messages) aes-gcm = "0.10" +base64 = "0.22" sha2 = "0.10" data-encoding = "2" hkdf = "0.12" diff --git a/assets/icons/compass.svg b/assets/icons/compass.svg new file mode 100644 index 00000000..6cd227dc --- /dev/null +++ b/assets/icons/compass.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/folder.svg b/assets/icons/folder.svg new file mode 100644 index 00000000..65967a9b --- /dev/null +++ b/assets/icons/folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/history.svg b/assets/icons/history.svg new file mode 100644 index 00000000..e7c76e68 --- /dev/null +++ b/assets/icons/history.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/message.svg b/assets/icons/message.svg new file mode 100644 index 00000000..87e04016 --- /dev/null +++ b/assets/icons/message.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/auto_update/src/lib.rs b/crates/auto_update/src/lib.rs index ce962806..92b4c63d 100644 --- a/crates/auto_update/src/lib.rs +++ b/crates/auto_update/src/lib.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task, Window}; +use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task}; use gpui_updater_core::{EngineConfig, Release, UpdateEngine, UpdateStatus, Verification, Version}; use instant::Duration; @@ -35,7 +35,7 @@ fn uses_managed_updates() -> bool { } /// Initialize the auto-update system. -pub fn init(window: &mut Window, cx: &mut App) { +pub fn init(cx: &mut App) { if uses_managed_updates() { log::info!( "Skipping auto-update initialization: updates are managed by the installed distribution channel (Flatpak/Snap)" @@ -60,10 +60,7 @@ pub fn init(window: &mut Window, cx: &mut App) { return; }; - AutoUpdater::set_global( - cx.new(|cx| AutoUpdater::new(window, version, filter, cx)), - cx, - ); + AutoUpdater::set_global(cx.new(|cx| AutoUpdater::new(version, filter, cx)), cx); } struct GlobalAutoUpdater(Entity); @@ -103,21 +100,17 @@ impl AutoUpdater { cx.set_global(GlobalAutoUpdater(state)); } - fn new( - window: &mut Window, - version: Version, - filter: AssetFilter, - cx: &mut Context, - ) -> Self { + fn new(version: Version, filter: AssetFilter, cx: &mut Context) -> Self { + let entity = cx.entity().downgrade(); let source = GiteaSource::new(GITEA_API_BASE, GITEA_REPO_OWNER, GITEA_REPO_NAME, filter); let config = EngineConfig::new(version.clone()).verification(Verification::Checksum); let engine = Arc::new(UpdateEngine::new(source, config)); // Schedule an auto-check after a 2-minute delay - cx.defer_in(window, |_this, _window, cx| { - cx.spawn(async move |this, cx| { + cx.defer(move |cx| { + cx.spawn(async move |cx| { cx.background_executor().timer(AUTO_CHECK_DELAY).await; - this.update(cx, |this, cx| this.check(cx)).ok(); + entity.update(cx, |this, cx| this.check(cx)).ok(); }) .detach(); }); diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index 0563c87c..d9b22462 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -26,8 +26,8 @@ pub use state::FileAttachment; /// A static keypair used only for signing locally-cached rumor events. static LOCAL_KEYS: LazyLock = LazyLock::new(Keys::generate); -pub fn init(window: &mut Window, cx: &mut App) { - ChatRegistry::set_global(cx.new(|cx| ChatRegistry::new(window, cx)), cx); +pub fn init(cx: &mut App) { + ChatRegistry::set_global(cx.new(ChatRegistry::new), cx); } struct GlobalChatRegistry(Entity); @@ -150,7 +150,8 @@ impl ChatRegistry { } /// Create a new chat registry instance - fn new(window: &mut Window, cx: &mut Context) -> Self { + fn new(cx: &mut Context) -> Self { + let entity = cx.entity().downgrade(); let nostr = NostrRegistry::global(cx); let (tx, rx) = flume::unbounded::(); let mut subscriptions = smallvec![]; @@ -167,9 +168,12 @@ impl ChatRegistry { }), ); - // Run at the end of the current cycle - cx.defer_in(window, |this, _window, cx| { - this.get_rooms(cx); + cx.defer(move |cx| { + entity + .update(cx, |this, cx| { + this.get_rooms(cx); + }) + .ok(); }); Self { @@ -221,7 +225,21 @@ impl ChatRegistry { }; match *message { - RelayMessage::Event { event, .. } => { + RelayMessage::Event { + subscription_id, + event, + .. + } => { + let chat_sub = subscription_id.as_str() != sub_id1.as_str(); + let device_sub = subscription_id.as_str() != sub_id2.as_str(); + + // Concord wraps are also kind 1059. + // + // Only the two gift wrap subscriptions carry NIP-59 wraps for this account. + if event.kind == Kind::GiftWrap && chat_sub && device_sub { + continue; + } + // Prune the dedup set before it grows unbounded if processed_events.len() >= MAX_PROCESSED { processed_events.clear(); diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index 094209b0..8bcb3af9 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -289,12 +289,21 @@ impl Room { } } - /// Gets the display image for the room - pub fn display_image(&self, cx: &App) -> SharedString { - if !self.is_group() { - self.display_member(cx).avatar() + /// Gets the display picture for the room, if it has one + pub fn display_image(&self, cx: &App) -> Option { + if self.is_group() { + None } else { - SharedString::from("brand/group.png") + self.display_member(cx).avatar() + } + } + + /// A stable seed for the room's generated avatar + pub fn display_image_seed(&self, cx: &App) -> SharedString { + if self.is_group() { + SharedString::from(self.id.to_string()) + } else { + self.display_member(cx).avatar_seed() } } diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 0b54972d..c6a23955 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -1203,6 +1203,7 @@ impl ChatPanel { if show_author { this.child( Avatar::new(author.avatar()) + .seed(author.avatar_seed()) .flex_shrink_0() .relative() .dropdown_menu(move |this, _window, _cx| { @@ -1470,7 +1471,7 @@ impl ChatPanel { h_flex() .gap_1() .font_semibold() - .child(Avatar::new(avatar).small()) + .child(Avatar::new(avatar).seed(profile.avatar_seed()).small()) .child(name.clone()), ), ) @@ -1978,11 +1979,12 @@ impl Panel for ChatPanel { self.room .read_with(cx, |this, cx| { let label = this.display_name(cx); - let url = this.display_image(cx); + let picture = this.display_image(cx); + let seed = this.display_image_seed(cx); h_flex() .gap_1p5() - .child(Avatar::new(url).xsmall()) + .child(Avatar::new(picture).seed(seed).xsmall()) .child(label) .into_any_element() }) diff --git a/crates/community/Cargo.toml b/crates/community/Cargo.toml new file mode 100644 index 00000000..b4811929 --- /dev/null +++ b/crates/community/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "community" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +concord = { path = "../concord" } +state = { path = "../state" } + +gpui.workspace = true +nostr-sdk.workspace = true + +anyhow.workspace = true +flume.workspace = true +futures.workspace = true +log.workspace = true +serde_json.workspace = true +smallvec.workspace = true +smol.workspace = true + +[dev-dependencies] +nostr-memory.workspace = true diff --git a/crates/community/src/cache.rs b/crates/community/src/cache.rs new file mode 100644 index 00000000..12ce5e18 --- /dev/null +++ b/crates/community/src/cache.rs @@ -0,0 +1,331 @@ +use std::collections::BTreeMap; +use std::sync::LazyLock; + +use anyhow::{Result, anyhow}; +use concord::cord01::OpenedStream; +use concord::state::{CommunityState, STATE_PREFIX, state_identifier}; +use concord::{ChannelId, CommunityId, cord03}; +use nostr_sdk::prelude::*; + +static LOCAL_KEYS: LazyLock = LazyLock::new(|| { + Keys::new(SecretKey::from_slice(&[0x43; 32]).expect("a fixed 32-byte scalar is a valid key")) +}); + +const CHANNEL_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_C; +const MARK_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_T; +const MARK_VALUE: &str = "concord"; +const WRAP_TAG: &str = "e"; +const KIND_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_K; + +/// An already-expired rumor is refused at ingest. Returns whether it was kept. +pub async fn cache_rumor( + client: &Client, + channel: &ChannelId, + opened: &OpenedStream, +) -> Result { + let at = Timestamp::from_secs(opened.at_ms / 1000); + + if cord03::expiration_of(&opened.rumor)? + .is_some_and(|expiration| expiration <= Timestamp::now()) + { + return Ok(false); + } + + let tags = vec![ + Tag::identifier(opened.rumor_id), + Tag::custom(KIND_TAG.as_str(), [opened.rumor.kind.to_string()]), + Tag::custom(WRAP_TAG, [opened.wrapper_id.to_string()]), + Tag::custom(MARK_TAG.as_str(), [MARK_VALUE]), + Tag::custom(CHANNEL_TAG.as_str(), [channel.to_hex()]), + Tag::public_key(opened.author), + ]; + + let event = EventBuilder::new(Kind::ApplicationSpecificData, opened.rumor.as_json()) + .tags(tags) + .custom_created_at(at) + .finalize_async(&*LOCAL_KEYS) + .await?; + + client.database().save_event(&event).await?; + + Ok(true) +} + +pub async fn purge_expired(client: &Client, channel: &ChannelId, now: Timestamp) -> Result { + let filter = Filter::new() + .kind(Kind::ApplicationSpecificData) + .custom_tag(MARK_TAG, MARK_VALUE) + .custom_tag(CHANNEL_TAG, channel.to_hex()); + + let mut expired = Vec::new(); + + for event in client.database().query(filter).await? { + let Ok(rumor) = UnsignedEvent::from_json(&event.content) else { + continue; + }; + + let Ok(Some(expiration)) = cord03::expiration_of(&rumor) else { + continue; + }; + + if expiration <= now { + expired.push(event.id); + } + } + + let purged = expired.len(); + + if purged > 0 { + client.database().delete(Filter::new().ids(expired)).await?; + } + + Ok(purged) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Observed { + pub author: PublicKey, + pub at_ms: u64, +} + +/// The cached rumors of `channel`, keyed by the wrap they were opened from. +pub async fn wrapper_index( + client: &Client, + channel: &ChannelId, +) -> Result> { + let filter = Filter::new() + .kind(Kind::ApplicationSpecificData) + .custom_tag(MARK_TAG, MARK_VALUE) + .custom_tag(CHANNEL_TAG, channel.to_hex()); + + let mut index = BTreeMap::new(); + + for event in client.database().query(filter).await? { + let (Some(wrapper_id), Some(author)) = ( + event.tags.event_ids().next(), + event.tags.public_keys().next(), + ) else { + continue; + }; + + index.insert( + wrapper_id, + Observed { + author, + at_ms: event.created_at.as_secs().saturating_mul(1000), + }, + ); + } + + Ok(index) +} + +/// Cached rumors for `channel`, newest first, deduplicated by rumor id. +pub async fn query_rumors( + client: &Client, + channel: &ChannelId, + until: Option, + limit: usize, + kinds: Option<&[u16]>, +) -> Result> { + let mut filter = Filter::new() + .kind(Kind::ApplicationSpecificData) + .custom_tag(MARK_TAG, MARK_VALUE) + .custom_tag(CHANNEL_TAG, channel.to_hex()); + + if let Some(kinds) = kinds { + filter = filter.custom_tags(KIND_TAG, kinds.iter().map(u16::to_string)); + } + + if let Some(until) = until { + filter = filter.until(until); + } + + let mut newest: BTreeMap = BTreeMap::new(); + for event in client.database().query(filter).await? { + let Some(rumor_id) = event.tags.identifier() else { + continue; + }; + + match newest.get(&rumor_id) { + Some(existing) if existing.created_at >= event.created_at => {} + _ => { + newest.insert(rumor_id, event); + } + } + } + + let mut events: Vec = newest.into_values().collect(); + events.sort_by_key(|event| std::cmp::Reverse(event.created_at)); + events.truncate(limit); + + let mut rumors = Vec::with_capacity(events.len()); + for event in events { + let rumor = UnsignedEvent::from_json(event.content) + .map_err(|error| anyhow!("cached rumor is not a valid event: {error}"))?; + rumors.push(rumor); + } + + Ok(rumors) +} + +pub async fn save_state(client: &Client, state: &CommunityState) -> Result<()> { + let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?) + .tags([Tag::identifier(state.identifier())]) + .finalize_async(&*LOCAL_KEYS) + .await?; + + client.database().save_event(&event).await?; + + Ok(()) +} + +pub async fn load_state(client: &Client, id: &CommunityId) -> Result> { + let filter = Filter::new() + .kind(Kind::ApplicationSpecificData) + .identifier(state_identifier(id)) + .limit(1); + + match client.database().query(filter).await?.into_iter().next() { + Some(event) => Ok(Some(serde_json::from_str(&event.content)?)), + None => Ok(None), + } +} + +/// The newest state document per community carried in the local database. +pub async fn load_states(client: &Client) -> Result> { + let filter = Filter::new().kind(Kind::ApplicationSpecificData); + let mut newest: BTreeMap = BTreeMap::new(); + + for event in client.database().query(filter).await? { + let Some(id) = state_document_of(&event) else { + continue; + }; + + match newest.get(&id) { + Some(existing) if existing.created_at >= event.created_at => {} + _ => { + newest.insert(id, event); + } + } + } + + let mut states = Vec::with_capacity(newest.len()); + + for event in newest.into_values() { + match serde_json::from_str::(&event.content) { + Ok(state) => states.push(state), + Err(error) => log::warn!("ignoring malformed community state {}: {error}", event.id), + } + } + + Ok(states) +} + +fn state_document_of(event: &Event) -> Option { + let identifier = event.tags.identifier()?; + let hex = identifier.strip_prefix(STATE_PREFIX)?; + hex.parse().ok() +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use concord::Epoch; + use nostr_memory::MemoryDatabase; + + use super::*; + + fn client() -> Client { + ClientBuilder::default() + .database(MemoryDatabase::unbounded()) + .build() + } + + #[test] + fn load_states_reads_one_document_per_community_and_ignores_other_documents() { + smol::block_on(async { + let client = client(); + + 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], + root_epoch: Epoch(0), + control_root: None, + control_pks: BTreeMap::new(), + channels: Vec::new(), + relays: Vec::new(), + heads: Vec::new(), + banned: BTreeSet::new(), + cursors: BTreeMap::new(), + held_roots: Vec::new(), + channel_cuts: BTreeMap::new(), + refounders: BTreeSet::new(), + removed_at: None, + stranded: false, + dissolved: false, + added_at_ms: 7, + }; + + save_state(&client, &state).await.expect("saves"); + + // A cached rumor is also an application-specific document, but not a + // state document, so the prefix keeps it out of the state scan. + let other = EventBuilder::new(Kind::ApplicationSpecificData, "{}") + .tags([Tag::identifier("deadbeef")]) + .finalize(&*LOCAL_KEYS) + .expect("builds"); + client.database().save_event(&other).await.expect("saves"); + + let loaded = load_states(&client).await.expect("loads"); + + assert_eq!(loaded, vec![state]); + }); + } + + #[test] + fn caching_the_same_rumor_twice_leaves_one_row() { + smol::block_on(async { + let client = client(); + let channel = ChannelId::from_bytes([0x9c; 32]); + let keys = Keys::generate(); + let group = concord::derive::channel_group_key(&[0x07; 32], &channel, Epoch(0)) + .expect("a group key"); + + let rumor = concord::cord03::build_message( + keys.public_key(), + &channel, + Epoch(0), + "twice", + None, + 1_700_000_000_000, + None, + ); + let (wrap, _) = concord::cord03::seal_rumor(&rumor, &group, &keys, false) + .await + .expect("seals"); + let (opened, _) = + concord::cord03::open(&wrap, &group, &channel, Epoch(0)).expect("opens"); + + assert!( + cache_rumor(&client, &channel, &opened) + .await + .expect("caches") + ); + assert!( + cache_rumor(&client, &channel, &opened) + .await + .expect("caches") + ); + + let cached = query_rumors(&client, &channel, None, 10, None) + .await + .expect("reads"); + assert_eq!(cached.len(), 1); + }); + } +} diff --git a/crates/community/src/community.rs b/crates/community/src/community.rs new file mode 100644 index 00000000..51656464 --- /dev/null +++ b/crates/community/src/community.rs @@ -0,0 +1,1259 @@ +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +use anyhow::Result; +use concord::cord02::{ControlFold, ImageRef}; +use concord::cord03::{self, ChatMessage, ReplyRef}; +use concord::cord04::AuthorityCitation; +use concord::cord04::roles::{Permissions, citation_ok}; +use concord::cord06::RekeyScope; +use concord::derive::{channel_group_key, grant_locator}; +use concord::state::{ChannelCursor, ChannelKeyRef, CommunityState, HeldKey, HeldRoot}; +use concord::{ChannelId, CommunityId, Epoch}; +use gpui::{App, AppContext, Context, EventEmitter, Task}; +use nostr_sdk::prelude::*; +use smallvec::{SmallVec, smallvec}; +use state::NostrRegistry; + +use crate::cache; +use crate::history::{self, PageRegistry, Window, WrapPage}; +use crate::rekey::{self, Adoptions}; +use crate::sync::{self, Snapshot}; + +/// Wraps one relay returns for one page request. +const PAGE_WRAPS: usize = 50; +/// Pages a catch-up round walks down a channel's history on its own. +const CATCH_UP_PAGES: usize = 20; +/// Pages one explicit "load older" fetches from the relays. +pub const LOAD_OLDER_PAGES: usize = 6; +/// Rows one timeline read returns before the caller asks for more. +pub const TIMELINE_PAGE: usize = 100; +/// Side events read per row, so a reaction flood cannot displace the rows it decorates. +const SIDE_EVENT_FACTOR: usize = 4; +/// The shortest gap between two automatic catch-up rounds for one channel. +pub const MIN_ROUND_INTERVAL: Duration = Duration::from_secs(30); +/// How long a channel may go unsynced before the scheduler repairs it. +const STALE_AFTER: Duration = Duration::from_secs(300); + +/// Which direction a sync round reads. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Intent { + CatchUp, + Older { pages: usize }, +} + +/// What one round saw, for the caller to report and act on. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Progress { + /// Wraps the round reached that no key this client holds can open. + pub unreadable: usize, + /// A relay refused or never answered a page. + pub failed: bool, + /// How many relays failed the round. + pub errors: usize, +} + +/// A channel's timeline, folded from the local cache. +#[derive(Debug, Clone, Default)] +pub struct Timeline { + /// Oldest first, ready for a bottom-aligned list. + pub messages: Vec, + /// The cache holds rows older than `messages`. + pub has_more: bool, +} + +/// One channel's round in flight, coalescing requests that arrive while it runs. +#[derive(Default)] +struct Round { + running: bool, + /// The next round to run once this one lands. + queued: Option, + /// Everyone waiting on the outcome. + waiters: Vec>>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubscriptionKey { + control_pks: BTreeMap, + channels: Vec<(ChannelId, Epoch, bool)>, + roots: Vec<(u64, [u8; 32])>, + 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(), + roots: state + .roots() + .into_iter() + .map(|root| (root.epoch.0, root.key)) + .collect(), + relays: state.relays.clone(), + } + } + + pub(crate) fn relays(&self) -> &[RelayUrl] { + &self.relays + } +} + +#[derive(Debug, Clone)] +pub enum CommunityEvent { + Updated(CommunityId), + Open(CommunityId), + Close(CommunityId), + Channel(CommunityId, ChannelId), + /// History exists here that no held key can open. + Unreadable(CommunityId), + /// The last round could not reach the community's relays. + Failed(CommunityId), + Error(String), +} + +pub struct Community { + state: CommunityState, + control: ControlFold, + members: BTreeSet, + active: Option, + icon: Option, + icon_ref: Option, + banner: Option, + banner_ref: Option, + dirty: bool, + refresh_task: Option>>, + icon_task: Option>>, + banner_task: Option>>, + rounds: HashMap, + /// The last completed round per channel, for the panel's honest states. + progress: HashMap, + /// Wraps held per channel that no key we hold can open. + unreadable: BTreeMap, + /// When each channel's last round started, which paces the automatic ones. + last_round: HashMap, + pages: PageRegistry, + rekey_task: Option>>, + rekey_dirty: bool, + /// Spawned folds, round bookkeeping and publishes, cancelled on drop + tasks: SmallVec<[Task>; 2]>, +} + +impl EventEmitter for Community {} + +impl Community { + pub fn new(state: CommunityState, pages: PageRegistry) -> Self { + Self { + state, + control: ControlFold::default(), + members: BTreeSet::new(), + active: None, + icon: None, + icon_ref: None, + banner: None, + banner_ref: None, + dirty: false, + refresh_task: None, + icon_task: None, + banner_task: None, + rounds: HashMap::new(), + progress: HashMap::new(), + unreadable: BTreeMap::new(), + last_round: HashMap::new(), + pages, + rekey_task: None, + rekey_dirty: false, + tasks: smallvec![], + } + } + + pub fn id(&self) -> CommunityId { + self.state.id + } + + pub fn state(&self) -> &CommunityState { + &self.state + } + + pub fn name(&self) -> String { + 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 { + self.icon.clone() + } + + /// The community's banner, once downloaded and decrypted into a cache file. + pub fn banner(&self) -> Option { + self.banner.clone() + } + + /// The channel the sidebar and panel show, defaulting to the first one. + pub fn active_channel(&self) -> Option { + self.active + .or_else(|| self.state.channels.first().map(|channel| channel.id)) + } + + /// Mark `channel` as the one the sidebar and panel show. + pub fn set_active_channel(&mut self, channel: ChannelId, cx: &mut Context) { + if self.active == Some(channel) { + return; + } + + self.active = Some(channel); + cx.emit(CommunityEvent::Channel(self.state.id, channel)); + } + + pub fn members(&self) -> &BTreeSet { + &self.members + } + + /// The base epoch a rotation excluded us at: readable history, no writes. + pub fn removed_at(&self) -> Option { + self.state.removed_at + } + + /// A complete rotation ahead of our epoch predates our join and + /// carries no blob for us, so the invite landed us on a superseded epoch. + pub fn stranded(&self) -> bool { + self.state.stranded + } + + /// Wraps held here that no key we hold can open. + pub fn unreadable(&self, channel: &ChannelId) -> usize { + self.unreadable.get(channel).copied().unwrap_or(0) + } + + /// The last round's outcome for `channel`, when one has run. + pub fn progress(&self, channel: &ChannelId) -> Option { + self.progress.get(channel).copied() + } + + /// The epoch a private channel's key is missing for, when we hold none. + pub fn missing_key(&self, channel: &ChannelId) -> Option { + self.state + .channels + .iter() + .find(|held| held.id == *channel && held.private && held.key.is_none()) + .map(|held| held.epoch) + } + + /// The epoch a channel rotation removed us at, when it removed us. + pub fn channel_removed_at(&self, channel: &ChannelId) -> Option { + self.state.channel_cuts.get(channel).copied() + } + + /// Whether an automatic catch-up for `channel` is worth asking for yet. + pub fn due(&self, channel: &ChannelId) -> bool { + self.last_round + .get(channel) + .is_none_or(|at| at.elapsed() >= MIN_ROUND_INTERVAL) + } + + /// Whether `channel` has been synced before and has since gone stale. + fn stale(&self, channel: &ChannelId) -> bool { + self.last_round + .get(channel) + .is_some_and(|at| at.elapsed() >= STALE_AFTER) + } + + pub fn channels(&self) -> &[ChannelKeyRef] { + &self.state.channels + } + + pub fn subscription_key(&self) -> SubscriptionKey { + SubscriptionKey::of(&self.state) + } + + /// A public channel derives its write plane from the community root. + fn channel_secret(&self, channel: &ChannelId) -> Option<(Epoch, [u8; 32])> { + if self.state.removed_at.is_some() || self.state.stranded { + return None; + } + + let held = self + .state + .channels + .iter() + .find(|held| held.id == *channel)?; + + if self.state.channel_cut(channel, held.epoch) { + return None; + } + + held.current().or_else(|| { + (!held.private).then_some((self.state.root_epoch, self.state.community_root)) + }) + } + + /// Every secret the client holds for a channel, newest epoch first. + fn held_keys(&self, channel: &ChannelId) -> Vec { + self.state.held_keys(channel) + } + + pub fn sync_channel( + &mut self, + channel: &ChannelId, + intent: Intent, + cx: &mut Context, + ) -> Task> { + let channel = *channel; + let (sender, receiver) = flume::bounded(1); + + let start = { + let round = self.rounds.entry(channel).or_default(); + round.waiters.push(sender); + + let start = !round.running; + + if !start { + round.queued = Some(intent); + } + + start + }; + + if start { + self.start_round(channel, intent, cx); + } + + cx.background_spawn(async move { + let progress = match receiver.recv_async().await { + Ok(progress) => progress, + Err(error) => { + log::warn!("community: channel round cancelled: {error}"); + return Ok(Progress { + failed: true, + ..Progress::default() + }); + } + }; + progress.map_err(anyhow::Error::msg) + }) + } + + fn start_round(&mut self, channel: ChannelId, intent: Intent, cx: &mut Context) { + let client = NostrRegistry::global(cx).read(cx).client(); + let held = self.held_keys(&channel); + let relays = self.state.relays.clone(); + let pages = self.pages.clone(); + + let saved = clamped( + self.state + .cursors + .get(&channel) + .copied() + .unwrap_or_default(), + Timestamp::now(), + ); + + if let Some(round) = self.rounds.get_mut(&channel) { + round.running = true; + round.queued = None; + } + + self.last_round.insert(channel, Instant::now()); + + let round = cx.background_spawn(async move { + sync_round(&client, &pages, &channel, &held, &relays, saved, intent).await + }); + + let finisher = cx.spawn(async move |this, cx| { + let result = round.await; + + if let Err(error) = this.update(cx, |this, cx| this.finish_round(channel, result, cx)) { + log::warn!("community: a channel round outlived its community: {error}"); + } + + Ok(()) + }); + + self.tasks.push(finisher); + } + + fn finish_round( + &mut self, + channel: ChannelId, + result: Result, + cx: &mut Context, + ) { + let outcome = match result { + Ok((progress, cursor)) => { + self.merge_cursor(channel, cursor, cx); + Ok(progress) + } + Err(error) => Err(error.to_string()), + }; + + let Some(round) = self.rounds.get_mut(&channel) else { + return; + }; + + round.running = false; + let queued = round.queued.take(); + let waiters = std::mem::take(&mut round.waiters); + + for waiter in waiters { + if let Err(error) = waiter.try_send(outcome.clone()) { + log::warn!("community: a channel round result was not delivered: {error}"); + } + } + + if let Some(intent) = queued { + self.start_round(channel, intent, cx); + } + + if let Ok(progress) = &outcome { + self.progress.insert(channel, *progress); + self.record_unreadable(channel, progress.unreadable); + + if progress.failed && progress.errors > 0 { + cx.emit(CommunityEvent::Failed(self.state.id)); + } + + if progress.unreadable > 0 { + cx.emit(CommunityEvent::Unreadable(self.state.id)); + } + } + + cx.emit(CommunityEvent::Updated(self.state.id)); + } + + /// Remember the wraps no held key could open. + fn record_unreadable(&mut self, channel: ChannelId, count: usize) { + if count == 0 { + return; + } + + let seen = self.unreadable.entry(channel).or_default(); + *seen = (*seen).max(count); + } + + fn missing_authority(&self) -> bool { + self.state.refounders.is_empty() && self.state.roots().len() > 1 + } + + pub(crate) fn tick(&mut self, cx: &mut Context) { + if self.missing_authority() { + self.rekey(cx); + } + + let Some(channel) = self.active_channel() else { + return; + }; + + if !self.stale(&channel) { + return; + } + + self.refresh(cx); + + let catch_up = self.sync_channel(&channel, Intent::CatchUp, cx); + + let task = cx.spawn(async move |_this, _cx| { + if let Err(error) = catch_up.await { + log::warn!("community: the scheduler's catch-up failed: {error}"); + } + + Ok(()) + }); + + self.tasks.push(task); + } + + /// Fold a round's cursor findings in, monotonically, and persist them. + fn merge_cursor(&mut self, channel: ChannelId, cursor: ChannelCursor, cx: &mut Context) { + let held = self + .state + .cursors + .get(&channel) + .copied() + .unwrap_or_default(); + + let merged = clamped(held.merge(cursor), Timestamp::now()); + + if merged == held { + return; + } + + self.state.cursors.insert(channel, merged); + self.persist(cx); + } + + /// Write the local state document out. + fn persist(&mut self, cx: &Context) { + let client = NostrRegistry::global(cx).read(cx).client(); + let state = self.state.clone(); + + let task = cx.background_spawn(async move { + if let Err(error) = cache::save_state(&client, &state).await { + log::warn!("community: failed to persist the local state: {error}"); + } + + Ok(()) + }); + + self.tasks.push(task); + } + + /// The channel's timeline, folded from the local cache, oldest first. + pub fn timeline( + &self, + channel: &ChannelId, + before_ms: Option, + limit: usize, + cx: &App, + ) -> Task> { + let client = NostrRegistry::global(cx).read(cx).client(); + let channel = *channel; + let owner = self.state.owner; + let community_id = self.state.id; + let floors = self.state.floors(); + let roles = self.control.roles.clone(); + + cx.background_spawn(async move { + let until = before_ms.map(|before_ms| Timestamp::from_secs(before_ms / 1000)); + + let rows = cache::query_rumors( + &client, + &channel, + until, + limit.saturating_add(1), + Some(&cord03::ROW_KINDS), + ) + .await?; + + let has_more = rows.len() > limit; + + let sides = cache::query_rumors( + &client, + &channel, + until, + limit.saturating_mul(SIDE_EVENT_FACTOR), + Some(&cord03::SIDE_KINDS), + ) + .await?; + + let mut rumors = Vec::with_capacity(rows.len() + sides.len()); + + for rumor in rows.iter().take(limit).chain(sides.iter()) { + match cord03::parse_rumor(rumor) { + Ok(chat) => rumors.push(chat), + Err(error) => { + log::warn!("community: skipping an unreadable cached rumor: {error}") + } + } + } + + let mut messages = + cord03::fold(&rumors, Timestamp::now(), |actor, citation, author| { + citation_ok(&owner, &community_id, actor, citation, &floors) + && roles.can_act_on_member( + actor, + &owner, + author, + Permissions::MANAGE_MESSAGES, + ) + }); + + messages.reverse(); + + Ok(Timeline { messages, has_more }) + }) + } + + /// Seal a message to the channel plane, cache it, then publish it to the relays. + pub fn send( + &self, + channel: &ChannelId, + content: &str, + reply_to: Option, + cx: &App, + ) -> Option>> { + let (epoch, secret) = self.channel_secret(channel)?; + + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let signer = nostr.read(cx).signer(); + let author = nostr.read(cx).current_user()?; + + let channel = *channel; + let relays = self.state.relays.clone(); + let timer = self + .control + .community + .as_ref() + .and_then(|metadata| metadata.message_expiration); + let content = content.to_owned(); + + Some(cx.background_spawn(async move { + let group = channel_group_key(&secret, &channel, epoch)?; + let at_ms = now_ms()?; + + let rumor = cord03::build_message( + author, + &channel, + epoch, + &content, + reply_to.as_ref(), + at_ms, + timer, + ); + let (wrap, _) = cord03::seal_rumor(&rumor, &group, &signer, false).await?; + + let (opened, _) = cord03::open(&wrap, &group, &channel, epoch)?; + cache::cache_rumor(&client, &channel, &opened).await?; + + sync::connect_relays(&client, &relays).await; + sync::publish_wrap(&client, &wrap, &relays).await; + + Ok(opened.rumor_id) + })) + } + + /// Adopt plane material the account's list now carries. + /// + /// The caller re-folds afterwards; this only seeds the new planes. + pub(crate) fn adopt(&mut self, state: CommunityState) { + // A fold already in flight would write its pre-adoption state back. + self.refresh_task = None; + self.dirty = false; + self.state = state; + } + + /// Adopt whatever the rekey watch has delivered, then re-page what it moved. + pub fn rekey(&mut self, cx: &mut Context) { + if self.rekey_task.is_some() { + self.rekey_dirty = true; + return; + } + + let nostr = NostrRegistry::global(cx); + let Some(me) = nostr.read(cx).current_user() else { + return; + }; + + let client = nostr.read(cx).client(); + let signer = nostr.read(cx).signer(); + let state = self.state.clone(); + let roles = self.control.roles.clone(); + + let task = + cx.background_spawn( + async move { rekey::adopt(&client, &state, &roles, &signer, me).await }, + ); + + self.rekey_task = Some(cx.spawn(async move |this, cx| { + let result = task.await; + this.update(cx, |this, cx| this.apply_rekey(result, cx))?; + Ok(()) + })); + } + + /// Rotate one scope to its next epoch, cutting off `excluded`. + pub fn rotate( + &mut self, + scope: RekeyScope, + recipients: &[PublicKey], + excluded: &[PublicKey], + cx: &mut Context, + ) -> Option>> { + let nostr = NostrRegistry::global(cx); + let me = nostr.read(cx).current_user()?; + + let client = nostr.read(cx).client(); + let signer = nostr.read(cx).signer(); + + if self.state.removed_at.is_some() || self.state.stranded || self.state.banned.contains(&me) + { + return None; + } + + let rewrite = rekey::Rewrite { + scope, + recipients: recipients.to_vec(), + excluded: excluded.to_vec(), + citation: self.citation(&me), + }; + + if !rewrite.authorized(&self.control.roles, &self.state.owner, &me) { + return None; + } + + let state = self.state.clone(); + let roles = self.control.roles.clone(); + + Some(cx.spawn(async move |this, cx| { + let epoch = rekey::rotate( + &client, + &state, + &roles, + &signer, + me, + &rewrite, + Timestamp::now(), + ) + .await?; + + let adoptions = rekey::adopt(&client, &state, &roles, &signer, me).await?; + + this.update(cx, |this, cx| { + if !adoptions.is_empty() { + this.merge_adoptions(adoptions, cx); + } + })?; + + Ok(epoch) + })) + } + + /// The rank this client cites when it acts. + fn citation(&self, me: &PublicKey) -> Option { + let entity = grant_locator(&self.state.id, &me.to_bytes()); + + self.state + .heads + .iter() + .find(|head| head.entity == entity) + .map(|head| AuthorityCitation { + entity: head.entity, + version: head.version, + hash: head.self_hash, + }) + } + + fn apply_rekey(&mut self, result: Result, cx: &mut Context) { + self.rekey_task = None; + + match result { + Ok(adoptions) if !adoptions.is_empty() => self.merge_adoptions(adoptions, cx), + Ok(_) => {} + Err(error) => cx.emit(CommunityEvent::Error(error.to_string())), + } + + if self.rekey_dirty { + self.rekey_dirty = false; + self.rekey(cx); + } + } + + /// Fold an adoption into the held state, persist it, and re-page what moved. + fn merge_adoptions(&mut self, adoptions: Adoptions, cx: &mut Context) { + let mut touched: Vec = Vec::new(); + + if let Some(base) = adoptions.base { + let mut held = Vec::with_capacity(base.stepped.len() + self.state.held_roots.len()); + + for key in base.stepped { + let known = self + .state + .held_roots + .iter() + .chain(held.iter()) + .any(|root| root.epoch == key.epoch && root.key == key.key); + + if known { + continue; + } + + held.push(HeldRoot { + epoch: key.epoch, + key: key.key, + control_pk: self.state.control_pks.get(&key.epoch.0).copied(), + retired_at: key.retired_at, + }); + } + + for root in &self.state.held_roots { + if held + .iter() + .any(|kept| kept.epoch == root.epoch && kept.key == root.key) + { + continue; + } + + held.push(*root); + } + + self.state.held_roots = held; + + if let Some(control_pk) = base.control_pk { + self.state.control_pks.insert(base.epoch.0, control_pk); + } + + // The rotation is authoritative about the new epoch's signing root + self.state.control_root = base.control_root; + self.state.community_root = base.key; + self.state.root_epoch = base.epoch; + self.state.removed_at = None; + self.state.stranded = false; + + // Every channel's plane moved with the root. + touched.extend(self.state.channels.iter().map(|channel| channel.id)); + } + + for channel in adoptions.channels { + let Some(held) = self + .state + .channels + .iter_mut() + .find(|held| held.id == channel.channel) + else { + continue; + }; + + // `stepped` carries the key held before the walk plus every epoch it + // passed through, each with the cutoff its superseding rotation set. + for key in channel.stepped { + if !held.priors.iter().any(|prior| prior.epoch == key.epoch) { + held.priors.push(key); + } + } + + held.key = Some(channel.key); + held.epoch = channel.epoch; + held.private = true; + touched.push(channel.channel); + } + + for (channel, epoch) in adoptions.cuts { + self.state.channels.retain(|held| held.id != channel); + self.state.channel_cuts.insert(channel, epoch); + self.state.cursors.remove(&channel); + } + + // A rotation that delivered us no key still names the npub that minted the epoch. + let learned = adoptions + .refounders + .into_iter() + .filter(|refounder| self.state.refounders.insert(*refounder)) + .count(); + + if let Some(epoch) = adoptions.removed_at { + self.state.removed_at = Some(epoch); + } + + if adoptions.stranded { + self.state.stranded = true; + } + + for channel in &touched { + if let Some(cursor) = self.state.cursors.get_mut(channel) { + cursor.exhausted = false; + } + } + + self.persist(cx); + cx.notify(); + cx.emit(CommunityEvent::Updated(self.state.id)); + + if learned > 0 { + self.refresh(cx); + } + + let Some(channel) = self + .active + .or_else(|| self.state.channels.first().map(|channel| channel.id)) + else { + return; + }; + + let catch_up = self.sync_channel(&channel, Intent::CatchUp, cx); + + let task = cx.spawn(async move |_this, _cx| { + if let Err(error) = catch_up.await { + log::warn!("community: the catch-up after a rekey failed: {error}"); + } + + Ok(()) + }); + + self.tasks.push(task); + } + + /// Rebuilds the community from the wraps in the local database. + pub fn refresh(&mut self, cx: &mut Context) { + if self.refresh_task.is_some() { + self.dirty = true; + return; + } + + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let state = self.state.clone(); + let folded = cx.background_spawn(async move { sync::fold(&client, &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)) => { + let mut state = snapshot.state; + state.cursors = std::mem::take(&mut self.state.cursors); + self.state = state; + self.control = snapshot.control; + self.members = snapshot.members; + self.load_images(cx); + + // A fold reads every plane, so its count is the truth for every + // channel rather than a sample of the region one round read. + // Replacing it is what lets the count fall again once a key is + // adopted; a partial round only ever raises it. + let reported = !snapshot.unreadable.is_empty(); + self.unreadable = snapshot.unreadable; + + if reported { + cx.emit(CommunityEvent::Unreadable(self.state.id)); + } + + 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); + } + } + + /// Resolve the folded icon and banner into local files. + fn load_images(&mut self, cx: &mut Context) { + let (icon, banner) = match self.control.community.as_ref() { + Some(metadata) => (metadata.icon.clone(), metadata.banner.clone()), + None => (None, None), + }; + + self.load_icon(icon, cx); + self.load_banner(banner, cx); + } + + fn load_icon(&mut self, icon: Option, cx: &mut Context) { + 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_image(&icon, cx).await { + Ok(path) => { + this.update(cx, |this, cx| { + this.icon = Some(path); + cx.notify(); + })?; + } + Err(error) => log::warn!("community icon: {error}"), + } + Ok(()) + })); + } + + fn load_banner(&mut self, banner: Option, cx: &mut Context) { + if self.banner_ref == banner { + return; + } + + self.banner_ref = banner.clone(); + self.banner = None; + + let Some(banner) = banner else { + return; + }; + + self.banner_task = Some(cx.spawn(async move |this, cx| { + match sync::resolve_image(&banner, cx).await { + Ok(path) => { + this.update(cx, |this, cx| { + this.banner = Some(path); + cx.notify(); + })?; + } + Err(error) => log::warn!("community banner: {error}"), + } + Ok(()) + })); + } +} + +/// One round's progress and the cursor material it earned. +type RoundOutcome = (Progress, ChannelCursor); + +/// A cursor as a filter bound may use it. +fn clamped(cursor: ChannelCursor, now: Timestamp) -> ChannelCursor { + ChannelCursor { + newest: cursor.newest.map(|newest| newest.min(now)), + ..cursor + } +} + +/// The wall clock in epoch milliseconds: a rumor's `ms` tag carries the part of +/// the second the message was written in, and the fold orders rows by it. +fn now_ms() -> Result { + let elapsed = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|error| anyhow::anyhow!("the system clock is before the Unix epoch: {error}"))?; + + u64::try_from(elapsed.as_millis()) + .map_err(|error| anyhow::anyhow!("the system clock is out of range: {error}")) +} + +/// Read a channel's history from the community's relays, in three passes. +async fn sync_round( + client: &Client, + pages: &PageRegistry, + channel: &ChannelId, + held: &[HeldKey], + relays: &[RelayUrl], + saved: ChannelCursor, + intent: Intent, +) -> Result { + let mut progress = Progress::default(); + let mut round = ChannelCursor::default(); + + // Expired rows drop at fold time otherwise, never from disk. + let purged = cache::purge_expired(client, channel, Timestamp::now()).await?; + + if purged > 0 { + log::debug!( + "community: purged {purged} expired rumor(s) from {}", + channel.to_hex() + ); + } + + let newest = match intent { + Intent::CatchUp => { + let page = history::page( + client, + pages, + channel, + held, + relays, + Window::opening(saved), + 1, + PAGE_WRAPS, + ) + .await?; + absorb(&mut progress, &page); + page + } + Intent::Older { .. } => WrapPage::default(), + }; + + let bridge = match (intent, newest.oldest, saved.newest) { + (Intent::CatchUp, Some(oldest), Some(saved_newest)) if oldest > saved_newest => { + let page = history::page( + client, + pages, + channel, + held, + relays, + Window::between(saved_newest, oldest), + CATCH_UP_PAGES, + PAGE_WRAPS, + ) + .await?; + absorb(&mut progress, &page); + page + } + // Nothing to bridge, so the newest region is already complete. + _ => WrapPage { + exhausted: true, + ..WrapPage::default() + }, + }; + + let resume = match intent { + Intent::CatchUp => saved.oldest.or(newest.oldest), + Intent::Older { .. } => saved.oldest, + }; + + let budget = match intent { + Intent::CatchUp => CATCH_UP_PAGES, + Intent::Older { pages } => pages, + }; + + // A channel already swept to the bottom has nothing older to ask for. + let older = match (saved.exhausted, resume) { + (true, _) => WrapPage { + exhausted: true, + ..WrapPage::default() + }, + (false, Some(until)) => { + let page = history::page( + client, + pages, + channel, + held, + relays, + Window::older_than(until), + budget, + PAGE_WRAPS, + ) + .await?; + absorb(&mut progress, &page); + page + } + (false, None) => WrapPage::default(), + }; + + if intent == Intent::CatchUp { + let complete = !newest.failed && bridge.exhausted; + let top = newest + .newest + .unwrap_or_default() + .max(bridge.newest.unwrap_or_default()); + + if complete && !top.is_zero() { + round.newest = Some(top); + } + } + + round.oldest = older.oldest.or(newest.oldest); + round.exhausted = older.exhausted; + + Ok((progress, round)) +} + +fn absorb(progress: &mut Progress, page: &WrapPage) { + progress.unreadable += page.unreadable; + progress.failed |= page.failed; + progress.errors += page.errors; +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A stamp ahead of the local clock must never become a cursor. + /// + /// A durable `newest` past `now` bounds every later REQ below a region that + /// has not happened yet, so the channel receives nothing at all — not live, + /// not by round — while its older history keeps working, which is exactly + /// what a peer with a fast clock (or a hostile stamp) would cause. + #[test] + fn a_future_stamp_cannot_push_a_cursor_past_now() { + let now = Timestamp::now(); + let held = ChannelCursor { + newest: Some(now - Duration::from_secs(30)), + oldest: Some(now - Duration::from_secs(90)), + exhausted: false, + }; + let round = ChannelCursor { + newest: Some(now + Duration::from_secs(86_400)), + oldest: None, + exhausted: true, + }; + + let merged = clamped(held.merge(round), now); + + assert_eq!(merged.newest, Some(now), "the frontier stops at the clock"); + assert_eq!(merged.oldest, Some(now - Duration::from_secs(90))); + assert!(merged.exhausted, "the round's other findings still land"); + + // A cursor already stored past `now` heals instead of staying deaf. + let poisoned = ChannelCursor { + newest: Some(now + Duration::from_secs(86_400)), + ..ChannelCursor::default() + }; + + assert_eq!(clamped(poisoned, now).newest, Some(now)); + } + + /// One public channel under a held root, and nothing else. + fn state(channel: ChannelId) -> CommunityState { + CommunityState { + id: CommunityId::from_bytes([0x42; 32]), + name: None, + owner: Keys::generate().public_key(), + owner_salt: [0x01; 32], + community_root: [0x02; 32], + root_epoch: Epoch(0), + control_root: None, + control_pks: BTreeMap::new(), + channels: vec![ChannelKeyRef { + id: channel, + name: "general".to_owned(), + private: false, + epoch: Epoch(0), + key: None, + priors: Vec::new(), + }], + relays: Vec::new(), + heads: Vec::new(), + banned: BTreeSet::new(), + cursors: BTreeMap::new(), + held_roots: Vec::new(), + channel_cuts: BTreeMap::new(), + refounders: BTreeSet::new(), + removed_at: None, + stranded: false, + dissolved: false, + added_at_ms: 0, + } + } + + fn community(state: CommunityState) -> Community { + Community::new(state, PageRegistry::default()) + } + + /// A rotation that excluded us leaves no key to write under: a wrap sealed + /// with the retired root would reach nobody who rotated with it. + #[test] + fn a_removed_member_holds_no_write_key() { + let channel = ChannelId::from_bytes([0x9c; 32]); + + // A public channel writes from the community root while we hold it. + assert_eq!( + community(state(channel)).channel_secret(&channel), + Some((Epoch(0), [0x02; 32])) + ); + + // A base removal closes the community... + let mut removed = state(channel); + removed.removed_at = Some(Epoch(1)); + assert_eq!(community(removed).channel_secret(&channel), None); + + // ...a strand closes it too, because the rotation moved past our epoch... + let mut stranded = state(channel); + stranded.stranded = true; + assert_eq!(community(stranded).channel_secret(&channel), None); + + // ...and a channel cut closes the one channel. + let mut cut = state(channel); + cut.channel_cuts.insert(channel, Epoch(0)); + assert_eq!(community(cut).channel_secret(&channel), None); + } +} diff --git a/crates/community/src/history.rs b/crates/community/src/history.rs new file mode 100644 index 00000000..7e8ecf4e --- /dev/null +++ b/crates/community/src/history.rs @@ -0,0 +1,764 @@ +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::future::Future; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::{Duration, Instant}; + +use anyhow::{Result, bail}; +use concord::cord01::{KIND_WRAP_EPHEMERAL, OpenedStream}; +use concord::cord03::{self, ChatRumor}; +use concord::derive::channel_group_key; +use concord::state::{ChannelCursor, HeldKey}; +use concord::{ChannelId, GroupKey}; +use futures::future::{Either, select}; +use nostr_sdk::prelude::*; + +use crate::cache::cache_rumor; +use crate::sync::connect_relays; + +/// How long one relay is given to answer one page of history. +const PAGE_TIMEOUT: Duration = Duration::from_secs(10); +/// How far below a cursor a warm window reaches back. +pub const CURSOR_OVERLAP: Duration = Duration::from_secs(60); + +/// The region of history to read. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct Window { + pub until: Option, + pub since: Option, +} + +impl Window { + /// The newest wraps, with no older bound. + pub fn newest() -> Self { + Self::default() + } + + /// The wraps strictly older than `oldest`. + pub fn older_than(oldest: Timestamp) -> Self { + Self { + until: Some(oldest - 1u64), + since: None, + } + } + + /// The region between `since` and `oldest`, both inclusive. + pub fn between(since: Timestamp, oldest: Timestamp) -> Self { + Self { + until: Some(oldest - 1u64), + since: Some(since), + } + } + + /// The window a channel is opened with. + pub fn opening(cursor: ChannelCursor) -> Self { + match cursor.newest { + Some(newest) => Self { + since: Some(newest - CURSOR_OVERLAP), + until: None, + }, + None => Self::default(), + } + } +} + +/// What a relay said about one page subscription. +#[derive(Debug, Clone)] +pub enum Settled { + Replayed, + Refused(String), +} + +/// One relay's verdict on one page subscription. +#[derive(Debug, Clone)] +pub struct PageReport { + pub relay: RelayUrl, + pub outcome: Settled, +} + +/// The page subscriptions in flight, by subscription id. +pub struct PageRegistry { + pages: Arc>>>, +} + +impl Default for PageRegistry { + fn default() -> Self { + Self { + pages: Arc::new(Mutex::new(HashMap::new())), + } + } +} + +impl Clone for PageRegistry { + fn clone(&self) -> Self { + Self { + pages: Arc::clone(&self.pages), + } + } +} + +impl PageRegistry { + pub fn register(&self, id: SubscriptionId, sender: flume::Sender) { + self.lock().insert(id, sender); + } + + pub fn unregister(&self, id: &SubscriptionId) { + self.lock().remove(id); + } + + /// Forget every page still in flight, because its round is gone. + pub fn clear(&self) { + self.lock().clear(); + } + + /// Hand a relay's verdict to the page that owns `id`, when it is still waiting. + pub fn deliver(&self, id: &SubscriptionId, relay: RelayUrl, outcome: Settled) { + let sender = self.lock().get(id).cloned(); + + let Some(sender) = sender else { + return; + }; + + if let Err(error) = sender.try_send(PageReport { relay, outcome }) { + log::debug!("community: a page report was not delivered: {error}"); + } + } + + fn lock(&self) -> MutexGuard<'_, HashMap>> { + match self.pages.lock() { + Ok(pages) => pages, + Err(poisoned) => poisoned.into_inner(), + } + } +} + +/// What one paged fetch saw. +#[derive(Debug, Clone, Default)] +pub struct WrapPage { + pub opened: Vec, + pub raw: usize, + /// Wraps that reached us under a held plane but that no held key could open. + pub unreadable: usize, + pub newest: Option, + pub oldest: Option, + pub exhausted: bool, + pub failed: bool, + pub errors: usize, +} + +/// Walks a channel's history back over the community's own relays. +#[allow(clippy::too_many_arguments)] +pub async fn page( + client: &Client, + pages: &PageRegistry, + channel: &ChannelId, + held: &[HeldKey], + relays: &[RelayUrl], + window: Window, + max_pages: usize, + limit: usize, +) -> Result { + let planes: Vec<(HeldKey, GroupKey)> = held + .iter() + .map(|key| Ok((*key, channel_group_key(&key.key, channel, key.epoch)?))) + .collect::>>()?; + let authors: Vec = planes.iter().map(|(_, group)| group.pk()).collect(); + + if authors.is_empty() || relays.is_empty() || limit == 0 { + return Ok(WrapPage { + failed: true, + ..WrapPage::default() + }); + } + + // A REQ can only target a relay the pool already knows about. + connect_relays(client, relays).await; + + let mut walk = Walk::new(relays, window); + let mut opened = Vec::new(); + + for _ in 0..max_pages { + if walk.is_done() { + break; + } + + let filter = wrap_filter(&authors, walk.region(), limit); + let asked: Vec<(usize, RelayUrl)> = walk + .live() + .map(|index| (index, walk.url(index).clone())) + .collect(); + + let answers = ask_page(client, pages, &asked, &filter).await; + + for (index, url) in &asked { + match answers.get(url) { + Some(Settled::Replayed) => {} + Some(Settled::Refused(reason)) => { + log::warn!("community: relay {url} refused a history page: {reason}"); + walk.reject(*index); + } + None => { + log::warn!("community: relay {url} did not finish a history page"); + walk.reject(*index); + } + } + } + + let answered = client.database().query(filter).await?; + + for wrap in walk.accept(answered, limit) { + let Some((held, group)) = planes.iter().find(|(_, group)| group.pk() == wrap.pubkey) + else { + continue; + }; + + let Ok((stream, rumor)) = read_under(&wrap, held, group, channel) else { + walk.unreadable += 1; + continue; + }; + + if cache_rumor(client, channel, &stream).await? { + opened.push(rumor); + } + } + } + + Ok(walk.finish(opened)) +} + +/// Opens one wrap under a held key. +fn read_under( + wrap: &Event, + held: &HeldKey, + group: &GroupKey, + channel: &ChannelId, +) -> Result<(OpenedStream, ChatRumor)> { + if held + .retired_at + .is_some_and(|retired| wrap.created_at > retired) + { + bail!("sealed after the key that reads it was retired"); + } + + Ok(cord03::open(wrap, group, channel, held.epoch)?) +} + +/// The one filter a page is asked for. +fn wrap_filter(authors: &[PublicKey], window: Window, limit: usize) -> Filter { + let mut filter = Filter::new() + .kinds([Kind::GiftWrap, Kind::Custom(KIND_WRAP_EPHEMERAL)]) + .authors(authors.iter().copied()) + .limit(limit); + + if let Some(until) = window.until { + filter = filter.until(until); + } + + if let Some(since) = window.since { + filter = filter.since(since); + } + + filter +} + +/// One page's verdicts, by relay. +type PageAnswers = BTreeMap; + +async fn ask_page( + client: &Client, + pages: &PageRegistry, + asked: &[(usize, RelayUrl)], + filter: &Filter, +) -> PageAnswers { + let mut answers = PageAnswers::new(); + let distinct: BTreeSet = asked.iter().map(|(_, url)| url.clone()).collect(); + let mut targets: Vec<(RelayUrl, Vec)> = Vec::with_capacity(distinct.len()); + + for url in &distinct { + match client.relay(url).await { + Ok(Some(_)) => targets.push((url.clone(), vec![filter.clone()])), + Ok(None) => { + log::warn!("community: relay {url} is not in the pool for a history page"); + answers.insert( + url.clone(), + Settled::Refused("not in the relay pool".to_owned()), + ); + } + Err(error) => { + log::warn!("community: relay {url} could not be looked up: {error}"); + answers.insert(url.clone(), Settled::Refused(error.to_string())); + } + } + } + + if targets.is_empty() { + return answers; + } + + let id = history_subscription(); + let (sender, receiver) = flume::bounded(distinct.len()); + pages.register(id.clone(), sender); + + let options = SubscribeAutoCloseOptions::default() + .exit_policy(ReqExitPolicy::ExitOnEOSE) + .timeout(Some(PAGE_TIMEOUT)); + + match client + .subscribe(ReqTarget::manual(targets)) + .with_id(id.clone()) + .close_on(options) + .await + { + Ok(output) => { + for (url, reason) in output.failed { + answers.insert(url, Settled::Refused(reason)); + } + + let deadline = Instant::now() + PAGE_TIMEOUT; + + while answers.len() < distinct.len() { + let remaining = deadline.saturating_duration_since(Instant::now()); + + let Some(report) = within(remaining, receiver.recv_async()).await else { + break; + }; + + match report { + Ok(report) => { + answers.insert(report.relay, report.outcome); + } + Err(error) => { + log::warn!("community: a history page's reports were lost: {error}"); + break; + } + } + } + } + Err(error) => { + log::warn!("community: a history page REQ was refused: {error}"); + } + } + + pages.unregister(&id); + + answers +} + +pub(crate) fn auth_required(reason: &str) -> bool { + matches!( + MachineReadablePrefix::parse(reason), + Some(MachineReadablePrefix::AuthRequired) + ) +} + +fn history_subscription() -> SubscriptionId { + static NEXT: AtomicU64 = AtomicU64::new(0); + SubscriptionId::new(format!("history-{}", NEXT.fetch_add(1, Ordering::Relaxed))) +} + +async fn within(limit: Duration, future: F) -> Option +where + F: Future, +{ + let future = std::pin::pin!(future); + let deadline = std::pin::pin!(smol::Timer::after(limit)); + + match select(future, deadline).await { + Either::Left((output, _)) => Some(output), + Either::Right(_) => None, + } +} + +/// One pass over a channel's history, page by page. +#[derive(Debug)] +struct Walk { + relays: Vec, + since: Option, + /// The inclusive upper bound of the next page. + cursor: Option, + seen: BTreeSet, + newest: Option, + oldest: Option, + raw: usize, + errors: usize, + /// Wraps the caller could not read under any held key. + unreadable: usize, + /// A short page ended the walk. + bottom: bool, +} + +/// One relay's standing in a walk. +#[derive(Debug)] +struct Walker { + url: RelayUrl, + dead: bool, +} + +impl Walk { + fn new(relays: &[RelayUrl], window: Window) -> Self { + Self { + relays: relays + .iter() + .cloned() + .map(|url| Walker { url, dead: false }) + .collect(), + since: window.since, + cursor: window.until, + seen: BTreeSet::new(), + newest: None, + oldest: None, + raw: 0, + errors: 0, + unreadable: 0, + bottom: false, + } + } + + fn is_done(&self) -> bool { + self.bottom || self.relays.iter().all(|walker| walker.dead) + } + + /// The region the next page asks for. + fn region(&self) -> Window { + Window { + until: self.cursor, + since: self.since, + } + } + + fn live(&self) -> impl Iterator + '_ { + (0..self.relays.len()).filter(|&index| !self.relays[index].dead) + } + + fn url(&self, index: usize) -> &RelayUrl { + &self.relays[index].url + } + + fn reject(&mut self, index: usize) { + self.relays[index].dead = true; + self.errors += 1; + } + + fn accept(&mut self, page: BTreeSet, limit: usize) -> Vec { + if page.len() < limit { + self.bottom = true; + } + + let mut oldest: Option = None; + let mut events = Vec::with_capacity(page.len()); + + for event in page { + let at = event.created_at; + self.newest = Some(self.newest.map_or(at, |newest| newest.max(at))); + oldest = Some(oldest.map_or(at, |oldest| oldest.min(at))); + + if self.seen.insert(event.id) { + self.raw += 1; + events.push(event); + } + } + + // The walk's floor, which the round resumes the older pass from. + if let Some(oldest) = oldest { + self.oldest = Some(self.oldest.map_or(oldest, |held| held.min(oldest))); + } + + match oldest { + Some(oldest) if !oldest.is_zero() => self.cursor = Some(oldest - 1u64), + Some(_) => self.bottom = true, + None => {} + } + + events + } + + fn finish(self, opened: Vec) -> WrapPage { + let swept = self.bottom && self.errors == 0; + + WrapPage { + opened, + raw: self.raw, + unreadable: self.unreadable, + newest: self.newest, + oldest: self.oldest, + exhausted: swept && self.raw > 0, + failed: self.errors > 0 || (self.bottom && self.raw == 0), + errors: self.errors, + } + } +} + +#[cfg(test)] +mod tests { + use std::cmp::Reverse; + + use concord::Epoch; + use concord::cord03::{build_message, seal_rumor}; + use concord::derive::channel_group_key; + + use super::*; + + const SECRET: [u8; 32] = [0x07u8; 32]; + const NEXT_SECRET: [u8; 32] = [0x11u8; 32]; + + fn serve_page(database: &BTreeSet, window: Window, limit: usize) -> BTreeSet { + let mut events: Vec = database + .iter() + .filter(|event| { + window.until.is_none_or(|until| event.created_at <= until) + && window.since.is_none_or(|since| event.created_at >= since) + }) + .cloned() + .collect(); + + events.sort_by_key(|event| Reverse(event.created_at)); + events.truncate(limit); + events.into_iter().collect() + } + + fn relay_url(host: &str) -> RelayUrl { + RelayUrl::parse(&format!("wss://{host}.example.com")).expect("parses") + } + + #[test] + fn a_walk_pages_back_across_a_rekey() { + let channel = ChannelId::from_bytes([0x9cu8; 32]); + let author = Keys::generate(); + let held = [ + HeldKey { + epoch: Epoch(0), + key: SECRET, + retired_at: None, + }, + HeldKey { + epoch: Epoch(1), + key: NEXT_SECRET, + retired_at: None, + }, + ]; + let planes: Vec<(HeldKey, GroupKey)> = held + .iter() + .map(|key| { + ( + *key, + channel_group_key(&key.key, &channel, key.epoch).expect("derives"), + ) + }) + .collect(); + + // Three messages a second apart: a page boundary falls between each. + let base = 1_700_000_000_000; + let mut relay: BTreeSet = BTreeSet::new(); + + for (content, secret, epoch, at_ms) in [ + ("before the rekey", &SECRET, Epoch(0), base), + ("still before", &SECRET, Epoch(0), base + 1_000), + ("after the rekey", &NEXT_SECRET, Epoch(1), base + 2_000), + ] { + let group = channel_group_key(secret, &channel, epoch).expect("derives"); + let rumor = build_message( + author.public_key(), + &channel, + epoch, + content, + None, + at_ms, + None, + ); + relay.insert( + smol::block_on(seal_rumor(&rumor, &group, &author, false)) + .expect("seals") + .0, + ); + } + + let mut walk = Walk::new(&[relay_url("history")], Window::newest()); + let mut found = Vec::new(); + let mut pages = 0; + + while !walk.is_done() && pages < 10 { + pages += 1; + let page = serve_page(&relay, walk.region(), 2); + + for wrap in walk.accept(page, 2) { + let Some((held, group)) = + planes.iter().find(|(_, group)| group.pk() == wrap.pubkey) + else { + continue; + }; + + let (_, rumor) = cord03::open(&wrap, group, &channel, held.epoch).expect("opens"); + found.push(rumor); + } + } + + found.sort_by_key(|rumor| (Reverse(rumor.at_ms), rumor.id)); + + let contents: Vec<&str> = found.iter().map(|rumor| rumor.content.as_str()).collect(); + assert_eq!( + contents, + ["after the rekey", "still before", "before the rekey"] + ); + + let page = walk.finish(Vec::new()); + assert!(page.exhausted); + assert!(!page.failed); + assert_eq!(page.raw, 3); + assert_eq!(page.newest, Some(Timestamp::from_secs(1_700_000_002))); + assert_eq!( + page.oldest, + Some(Timestamp::from_secs(1_700_000_000)), + "the walk reports its floor, which the older pass resumes below" + ); + } + + /// A wrap that reaches us and still will not open is history we cannot read, + /// not history that does not exist. + #[test] + fn a_wrap_no_held_key_can_open_reads_as_unreadable() { + let channel = ChannelId::from_bytes([0x9cu8; 32]); + let other = ChannelId::from_bytes([0x9du8; 32]); + let author = Keys::generate(); + let group = channel_group_key(&SECRET, &channel, Epoch(0)).expect("derives"); + let held = HeldKey { + epoch: Epoch(0), + key: SECRET, + retired_at: Some(Timestamp::from_secs(1_000)), + }; + + let wrap_at = |channel: &ChannelId, at_ms: u64| { + let rumor = build_message( + author.public_key(), + channel, + Epoch(0), + "sealed", + None, + at_ms, + None, + ); + smol::block_on(seal_rumor(&rumor, &group, &author, false)) + .expect("seals") + .0 + }; + + // Sealed before the rotation superseded this key, so it still reads. + let before = wrap_at(&channel, 999_000); + assert!(read_under(&before, &held, &group, &channel).is_ok()); + + // Sealed after the cutoff the rotation set on that key. + let after = wrap_at(&channel, 1_001_000); + assert!(read_under(&after, &held, &group, &channel).is_err()); + + // Sealed to this plane but bound to another channel. + let misbound = wrap_at(&other, 999_000); + assert!(read_under(&misbound, &held, &group, &channel).is_err()); + } + + #[test] + fn an_empty_answer_never_seals_the_channel() { + let database: BTreeSet = BTreeSet::new(); + let mut walk = Walk::new(&[relay_url("history")], Window::newest()); + + let page = serve_page(&database, walk.region(), 50); + assert!(walk.accept(page, 50).is_empty()); + + let page = walk.finish(Vec::new()); + assert!(page.failed); + assert!(!page.exhausted); + assert_eq!(page.raw, 0); + assert_eq!(page.oldest, None); + } + + #[test] + fn a_silent_relay_blocks_the_bottom() { + let database: BTreeSet = BTreeSet::new(); + let mut walk = Walk::new( + &[relay_url("history"), relay_url("archive")], + Window::newest(), + ); + + // One relay answered the empty page; the other never answered at all, so + // its share of the region was never read and the walk must not seal. + walk.reject(1); + let page = serve_page(&database, walk.region(), 50); + assert!(walk.accept(page, 50).is_empty()); + + let page = walk.finish(Vec::new()); + assert!(page.failed); + assert!(!page.exhausted); + assert_eq!(page.errors, 1); + } + + #[test] + fn a_page_boundary_is_exclusive() { + let database = BTreeSet::from([ + event_at(Timestamp::from_secs(1_700_000_000)), + event_at(Timestamp::from_secs(1_700_000_001)), + ]); + let mut walk = Walk::new(&[relay_url("history")], Window::newest()); + + let first = walk.accept(serve_page(&database, walk.region(), 1), 1); + let second = walk.accept(serve_page(&database, walk.region(), 1), 1); + + assert_eq!(first.len(), 1); + assert_eq!(second.len(), 1); + assert_ne!(first[0].id, second[0].id); + + let oldest = second + .iter() + .map(|event| event.created_at) + .min() + .expect("one wrap"); + assert_eq!(oldest, Timestamp::from_secs(1_700_000_000)); + } + + fn event_at(at: Timestamp) -> Event { + let keys = Keys::generate(); + EventBuilder::new(Kind::TextNote, "page") + .custom_created_at(at) + .finalize(&keys) + .expect("signs") + } + + /// A cold channel asks wide; a warm one resumes at the overlap above its + /// cursor, and `older_than` never includes the boundary event itself. + #[test] + fn a_cold_window_is_open_and_a_warm_one_resumes_at_the_overlap() { + assert_eq!(Window::opening(ChannelCursor::default()), Window::default()); + + let warm = Window::opening(ChannelCursor { + newest: Some(Timestamp::from_secs(2_000_000)), + oldest: Some(Timestamp::from_secs(1_000)), + exhausted: false, + }); + assert_eq!( + warm, + Window { + since: Some(Timestamp::from_secs(2_000_000) - CURSOR_OVERLAP), + until: None, + } + ); + + assert_eq!( + Window::older_than(Timestamp::from_secs(1_000)), + Window { + since: None, + until: Some(Timestamp::from_secs(999)), + } + ); + } + + /// A page whose round has moved on is simply a report nobody reads. + #[test] + fn a_page_that_moved_on_receives_nothing() { + let pages = PageRegistry::default(); + let id = SubscriptionId::new("concord-history-9"); + let (sender, receiver) = flume::bounded(1); + + pages.register(id.clone(), sender); + pages.unregister(&id); + pages.deliver(&id, relay_url("history"), Settled::Replayed); + + assert!(receiver.try_recv().is_err()); + } +} diff --git a/crates/community/src/lib.rs b/crates/community/src/lib.rs new file mode 100644 index 00000000..5b0dd7dd --- /dev/null +++ b/crates/community/src/lib.rs @@ -0,0 +1,850 @@ +use std::collections::{BTreeSet, HashMap}; +use std::time::{Duration, Instant}; + +use anyhow::Result; +pub use concord::cord02::CommunityMetadata; +pub use concord::cord03::{ChatMessage, ReplyRef}; +use concord::state::CommunityState; +pub use concord::{ChannelId, CommunityId, Epoch}; +use futures::future::{Either, select}; +use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, Window}; +use nostr_sdk::prelude::*; +use smallvec::{SmallVec, smallvec}; +use state::NostrRegistry; + +use crate::history::{PageRegistry, Settled, auth_required}; +use crate::rekey::WatchRegistry; + +pub mod cache; +mod community; +pub mod history; +mod rekey; +mod sync; + +pub use community::*; +pub use sync::*; + +/// How long a burst of relay notifications is collected before it is folded. +const PUMP_WINDOW: Duration = Duration::from_millis(200); +/// How long a community's relays may deliver nothing before +/// its standing subscription is torn down and re-issued. +const LIVE_ROTATE: Duration = Duration::from_secs(90); + +pub fn init(cx: &mut App) { + CommunityRegistry::set_global(cx.new(CommunityRegistry::new), cx); +} + +struct GlobalCommunityRegistry(Entity); + +impl Global for GlobalCommunityRegistry {} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Signal { + Event(CommunityId), + List, + Rekey(CommunityId), +} + +/// Which standing subscription an id belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Route { + List, + Community(CommunityId), +} + +fn route_of(id: &SubscriptionId) -> Option { + if sync::is_list_subscription(id) { + return Some(Route::List); + } + + sync::community_of(id).map(Route::Community) +} + +/// What a window of relay notifications saw, waiting to be folded once. +#[derive(Default)] +struct Batch { + list: bool, + communities: BTreeSet, + rekeys: BTreeSet, +} + +/// Whether the pump should keep listening. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Flow { + Continue, + Stop, +} + +/// Fold one notification into the window, or settle a page it belongs to. +fn route( + notification: ClientNotification, + pages: &PageRegistry, + watches: &WatchRegistry, + batch: &mut Batch, +) -> Flow { + match notification { + ClientNotification::Event { + subscription_id, .. + } => match route_of(&subscription_id) { + Some(Route::List) => batch.list = true, + Some(Route::Community(id)) => { + batch.communities.insert(id); + } + None => { + if let Some(id) = watches.community_of(&subscription_id) { + batch.rekeys.insert(id); + } + } + }, + ClientNotification::Message { relay_url, message } => match *message { + RelayMessage::EndOfStoredEvents(id) => { + pages.deliver(&id, relay_url, Settled::Replayed); + } + RelayMessage::Closed { + subscription_id, + message, + } if !auth_required(&message) => { + pages.deliver( + &subscription_id, + relay_url, + Settled::Refused(message.into_owned()), + ); + } + _ => {} + }, + ClientNotification::Shutdown => return Flow::Stop, + } + + Flow::Continue +} + +/// Hand the window's signals to the foreground consumer, one per community. +async fn flush(tx: &flume::Sender, batch: &mut Batch) -> Result<()> { + for id in std::mem::take(&mut batch.communities) { + tx.send_async(Signal::Event(id)).await?; + } + + for id in std::mem::take(&mut batch.rekeys) { + tx.send_async(Signal::Rekey(id)).await?; + } + + if std::mem::take(&mut batch.list) { + tx.send_async(Signal::List).await?; + } + + Ok(()) +} + +impl EventEmitter for CommunityRegistry {} + +pub struct CommunityRegistry { + communities: Vec>, + index: HashMap>, + /// The plane set each community was last subscribed with + synced: HashMap, + /// When a relay last delivered something for a community, + /// which is the only evidence the standing subscription is alive. + last_event: HashMap, + /// One observer per tracked community, dropped on reset + observers: HashMap, + signal_tx: flume::Sender, + signal_rx: flume::Receiver, + /// The page subscriptions in flight, shared with the notification pump. + pages: PageRegistry, + /// The rekey watch subscriptions, resolved to their community. + watches: WatchRegistry, + tasks: SmallVec<[Task>; 2]>, + /// Notification listener task (cancelled on signer change) + notification_listener: Option>>, + /// Signal consumer task (cancelled on signer change) + signal_consumer: Option>>, + /// The round scheduler (cancelled on signer change) + scheduler: Option>>, + _subscriptions: SmallVec<[Subscription; 2]>, +} + +impl CommunityRegistry { + pub fn global(cx: &App) -> Entity { + cx.global::().0.clone() + } + + fn set_global(state: Entity, cx: &mut App) { + cx.set_global(GlobalCommunityRegistry(state)); + } + + fn new(cx: &mut Context) -> Self { + let entity = cx.entity().downgrade(); + let nostr = NostrRegistry::global(cx); + let (tx, rx) = flume::bounded::(256); + let mut subscriptions = smallvec![]; + + subscriptions.push(cx.subscribe(&nostr, |this, _nostr, event, cx| { + if event.signer_changed() { + this.reset(cx); + this.handle_notifications(cx); + this.subscribe_list(cx); + this.load(cx); + } + })); + + cx.defer(move |cx| { + entity + .update(cx, |this, cx| { + this.handle_notifications(cx); + if nostr.read(cx).current_user().is_some() { + this.subscribe_list(cx); + this.load(cx); + } + }) + .ok(); + }); + + Self { + communities: Vec::new(), + index: HashMap::new(), + synced: HashMap::new(), + last_event: HashMap::new(), + observers: HashMap::new(), + signal_tx: tx, + signal_rx: rx, + pages: PageRegistry::default(), + watches: WatchRegistry::default(), + tasks: smallvec![], + notification_listener: None, + signal_consumer: None, + scheduler: None, + _subscriptions: subscriptions, + } + } + + pub fn communities(&self) -> &[Entity] { + &self.communities + } + + pub fn community(&self, id: &CommunityId) -> Option> { + self.index.get(id).cloned() + } + + /// Ask the workspace to open a community's panel. + pub fn emit_community( + &mut self, + community: &Entity, + window: &mut Window, + cx: &mut Context, + ) { + let id = community.read(cx).id(); + + cx.defer_in(window, move |_this, _window, cx| { + cx.emit(CommunityEvent::Open(id)); + }); + } + + /// Ask the workspace to close a community's panel. + pub fn emit_close(&mut self, id: CommunityId, window: &mut Window, cx: &mut Context) { + cx.defer_in(window, move |_this, _window, cx| { + cx.emit(CommunityEvent::Close(id)); + }); + } + + /// Create a community owned by the current account and begin tracking it. + pub fn create(&mut self, metadata: CommunityMetadata, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let current_user = nostr.read(cx).current_user(); + + if current_user.is_none() { + cx.emit(CommunityEvent::Error( + "cannot create a community without an account".to_owned(), + )); + return; + } + + let signer = nostr.read(cx).signer(); + let client = nostr.read(cx).client(); + + let task = + cx.background_spawn(async move { sync::create(&client, &signer, &metadata).await }); + + self.tasks.push(cx.spawn(async move |this, cx| { + match task.await { + Ok(_state) => this.update(cx, |this, cx| this.load(cx))?, + Err(error) => { + this.update(cx, |_this, cx| { + cx.emit(CommunityEvent::Error(error.to_string())); + })?; + } + } + + Ok(()) + })); + } + + /// Forget the current account and cancel everything in flight. + pub fn reset(&mut self, cx: &mut Context) { + self.notification_listener = None; + self.signal_consumer = None; + self.scheduler = None; + self.tasks.clear(); + self.observers.clear(); + self.pages.clear(); + self.watches.clear(); + + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let ids: Vec = self.index.keys().copied().collect(); + + for id in ids { + let client = client.clone(); + let subscription = sync::subscription_id(&id); + let rekey = rekey::subscription_id(&id); + + self.tasks.push(cx.background_spawn(async move { + client.unsubscribe(&subscription).await?; + client.unsubscribe(&rekey).await?; + Ok(()) + })); + } + + self.communities.clear(); + self.index.clear(); + self.synced.clear(); + self.last_event.clear(); + + cx.notify(); + } + + /// Subscribe to the account's community list. + fn subscribe_list(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let signer = nostr.read(cx).signer(); + let client = nostr.read(cx).client(); + + self.tasks.push(cx.spawn(async move |this, cx| { + let self_pk = signer.get_public_key_async().await?; + + if let Err(error) = sync::subscribe_list(&client, self_pk).await { + this.update(cx, |_this, cx| { + cx.emit(CommunityEvent::Error(error.to_string())); + })?; + } + + Ok(()) + })); + } + + /// Discover the account's communities in the local database. + fn load(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let signer = nostr.read(cx).signer(); + let client = nostr.read(cx).client(); + + let task = cx.background_spawn(async move { + let self_pk = signer.get_public_key_async().await?; + sync::load(&client, &signer, self_pk).await + }); + + self.tasks.push(cx.spawn(async move |this, cx| { + match task.await { + Ok(states) => { + this.update(cx, |this, cx| this.track(states, cx))?; + } + Err(error) => { + this.update(cx, |_this, cx| { + cx.emit(CommunityEvent::Error(error.to_string())); + })?; + } + } + + Ok(()) + })); + } + + /// Replace the tracked communities with a freshly loaded set. + /// + /// A community that survives the reload keeps its entity, so an open panel + /// and a browsing sidebar stay pointed at a live community. + fn track(&mut self, states: Vec, cx: &mut Context) { + let mut communities = Vec::with_capacity(states.len()); + + for state in states { + let id = state.id; + + let community = match self.index.remove(&id) { + Some(community) => { + // The list can carry plane material the store does not. + if community.read(cx).state() != &state { + community.update(cx, |community, _cx| community.adopt(state)); + } + + community + } + None => { + let community = cx.new(|_| Community::new(state, self.pages.clone())); + + self.observers.insert( + id, + cx.observe(&community, |this, _community, cx| { + this.sync_subscriptions(cx); + cx.notify(); + }), + ); + + community + } + }; + + communities.push((id, community)); + } + + // Whatever the index still holds is no longer in the list. + let dropped: Vec = self.index.keys().copied().collect(); + + for id in dropped { + self.observers.remove(&id); + self.synced.remove(&id); + self.last_event.remove(&id); + } + + self.communities = communities + .iter() + .map(|(_, community)| community.clone()) + .collect(); + self.index = communities.into_iter().collect(); + + self.sync_subscriptions(cx); + + // A backlog already in the database produces no notification, so fold it once. + for community in self.communities.clone() { + community.update(cx, |community, cx| community.refresh(cx)); + } + + cx.notify(); + } + + fn refresh(&mut self, id: CommunityId, cx: &mut Context) { + let Some(community) = self.index.get(&id).cloned() else { + return; + }; + + community.update(cx, |community, cx| community.refresh(cx)); + } + + /// Adopt whatever a community's rekey watch has delivered. + fn rekey(&mut self, id: CommunityId, cx: &mut Context) { + let Some(community) = self.index.get(&id).cloned() else { + return; + }; + + community.update(cx, |community, cx| community.rekey(cx)); + } + + /// One scheduler pass: every community repairs itself if it has gone stale, + /// and any whose relays have gone quiet is re-subscribed. + fn tick(&mut self, cx: &mut Context) { + for community in self.communities.clone() { + community.update(cx, |community, cx| community.tick(cx)); + } + self.rotate_quiet(cx); + } + + /// Re-issue the standing subscription of every community that has been quiet for `LIVE_ROTATE` + fn rotate_quiet(&mut self, cx: &mut Context) { + let now = Instant::now(); + let ids: Vec = self.index.keys().copied().collect(); + let mut rotated = false; + + for id in ids { + let quiet = self + .last_event + .get(&id) + .is_none_or(|at| now.duration_since(*at) >= LIVE_ROTATE); + + if !quiet { + continue; + } + + self.synced.remove(&id); + rotated = true; + } + + if rotated { + self.sync_subscriptions(cx); + } + } + + /// Re-subscribe every community whose held planes moved. + fn sync_subscriptions(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + + for community in self.communities.clone() { + let client = nostr.read(cx).client(); + + let (id, key, state) = { + let community = community.read(cx); + ( + community.id(), + community.subscription_key(), + community.state().clone(), + ) + }; + + if self.synced.get(&id) == Some(&key) { + continue; + } + + let planes = match sync::planes(&state) { + Ok(planes) => planes, + Err(error) => { + cx.emit(CommunityEvent::Error(error.to_string())); + continue; + } + }; + + let subscription = sync::subscription_id(&id); + let filter = sync::live_filter(&planes, sync::live_window(&state, Timestamp::now())); + let relays = key.relays().to_vec(); + + // A fresh REQ counts as evidence of life for this community. + self.last_event.insert(id, Instant::now()); + + // The rekey watch is a second standing REQ over the same relays. + let watch = match rekey::watches(&state) { + Ok(watches) => rekey::watch_filter(&watches), + Err(error) => { + cx.emit(CommunityEvent::Error(error.to_string())); + continue; + } + }; + + let rekey_subscription = rekey::subscription_id(&id); + self.watches.register(rekey_subscription.clone(), id); + + self.synced.insert(id, key); + + self.tasks.push(cx.spawn(async move |this, cx| { + if let Err(error) = subscribe(&client, &subscription, &relays, filter).await { + this.update(cx, |_this, cx| { + cx.emit(CommunityEvent::Error(error.to_string())); + })?; + } + + if let Err(error) = subscribe(&client, &rekey_subscription, &relays, watch).await { + this.update(cx, |_this, cx| { + cx.emit(CommunityEvent::Error(error.to_string())); + })?; + } + + Ok(()) + })); + } + } + + fn handle_notifications(&mut self, cx: &mut Context) { + self.notification_listener = None; + self.signal_consumer = None; + self.scheduler = None; + + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let tx = self.signal_tx.clone(); + let rx = self.signal_rx.clone(); + let pages = self.pages.clone(); + let watches = self.watches.clone(); + let executor = cx.background_executor().clone(); + + self.notification_listener = Some(cx.background_spawn(async move { + let mut notifications = client.notifications(); + let mut batch = Batch::default(); + + 'outer: loop { + match notifications.next().await { + Some(notification) => { + if route(notification, &pages, &watches, &mut batch) == Flow::Stop { + flush(&tx, &mut batch).await?; + break 'outer; + } + } + None => break 'outer, + } + + let deadline = Instant::now() + PUMP_WINDOW; + + loop { + let now = Instant::now(); + + if now >= deadline { + break; + } + + let timer = executor.timer(deadline - now); + let next = notifications.next(); + futures::pin_mut!(timer); + futures::pin_mut!(next); + + match select(next, timer).await { + Either::Left((Some(notification), _)) => { + if route(notification, &pages, &watches, &mut batch) == Flow::Stop { + flush(&tx, &mut batch).await?; + break 'outer; + } + } + Either::Left((None, _)) => break 'outer, + Either::Right(_) => break, + } + } + + flush(&tx, &mut batch).await?; + } + + Ok(()) + })); + + self.signal_consumer = Some(cx.spawn(async move |this, cx| { + while let Ok(signal) = rx.recv_async().await { + match signal { + Signal::Event(id) => this.update(cx, |this, cx| { + // The only proof a community's subscription is still + // delivering anything. + this.last_event.insert(id, Instant::now()); + this.refresh(id, cx); + })?, + Signal::Rekey(id) => this.update(cx, |this, cx| this.rekey(id, cx))?, + Signal::List => this.update(cx, |this, cx| this.load(cx))?, + } + } + Ok(()) + })); + + self.scheduler = Some(cx.spawn(async move |this, cx| { + loop { + cx.background_executor().timer(MIN_ROUND_INTERVAL).await; + + if let Some(registry) = this.upgrade() { + registry.update(cx, |this, cx| { + this.tick(cx); + }); + } else { + break; + } + } + + Ok(()) + })); + } +} + +async fn subscribe( + client: &Client, + id: &SubscriptionId, + relays: &[RelayUrl], + filter: Filter, +) -> Result<()> { + client.unsubscribe(id).await?; + + if relays.is_empty() { + log::warn!("community {id}: no relay to subscribe to"); + return Ok(()); + } + + let mut targets: Vec<(RelayUrl, Vec)> = Vec::with_capacity(relays.len()); + + for url in relays { + if let Err(error) = client.add_relay(url).and_connect().await { + log::warn!("community {id}: failed to add relay {url}: {error}"); + } + + match client.relay(url).await { + Ok(Some(_)) => targets.push((url.clone(), vec![filter.clone()])), + Ok(None) => log::warn!("community {id}: relay {url} is not in the pool"), + Err(error) => log::warn!("community {id}: relay {url} could not be looked up: {error}"), + } + } + + if targets.is_empty() { + log::warn!("community {id}: no relay accepted the standing subscription"); + return Ok(()); + } + + let output = client + .subscribe(ReqTarget::manual(targets)) + .with_id(id.clone()) + .await?; + + if !output.failed.is_empty() { + log::warn!( + "community {id}: {} relay(s) rejected the subscription", + output.failed.len() + ); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn relay() -> RelayUrl { + RelayUrl::parse("wss://relay.example").expect("a url") + } + + fn event(subscription_id: SubscriptionId) -> ClientNotification { + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::TextNote, "hi") + .finalize(&keys) + .expect("signs"); + + ClientNotification::Event { + relay_url: relay(), + subscription_id, + event: Box::new(event), + } + } + + fn message(message: RelayMessage<'static>) -> ClientNotification { + ClientNotification::Message { + relay_url: relay(), + message: Box::new(message), + } + } + + /// A burst within one window folds each community once, and the list once, + /// however many events arrived. + #[test] + fn a_burst_collapses_to_one_signal_per_community() { + let pages = PageRegistry::default(); + let watches = WatchRegistry::default(); + let community = CommunityId::from_bytes([0x42; 32]); + let mut batch = Batch::default(); + + let plane = event(sync::subscription_id(&community)); + for _ in 0..50 { + assert_eq!( + route(plane.clone(), &pages, &watches, &mut batch), + Flow::Continue + ); + } + + route( + event(sync::list_subscription_id()), + &pages, + &watches, + &mut batch, + ); + + // A page's event is folded from the database later, not routed here. + route( + event(SubscriptionId::new("concord-history-7")), + &pages, + &watches, + &mut batch, + ); + + assert!(batch.list); + assert_eq!(batch.communities, BTreeSet::from([community])); + assert!(batch.rekeys.is_empty()); + } + + /// A rekey watch's wraps put themselves in the database; the pump's only job + /// is to wake the adoption pass, and it resolves the community by id. + #[test] + fn a_rekey_watch_event_wakes_its_community() { + let pages = PageRegistry::default(); + let watches = WatchRegistry::default(); + let community = CommunityId::from_bytes([0x42; 32]); + let id = rekey::subscription_id(&community); + watches.register(id.clone(), community); + + let mut batch = Batch::default(); + route(event(id), &pages, &watches, &mut batch); + + assert_eq!(batch.rekeys, BTreeSet::from([community])); + assert!(batch.communities.is_empty()); + } + + #[test] + fn an_eose_settles_only_the_page_that_owns_the_id() { + let pages = PageRegistry::default(); + let mine = SubscriptionId::new("concord-history-1"); + let other = SubscriptionId::new("concord-history-2"); + let (mine_tx, mine_rx) = flume::bounded(1); + let (other_tx, other_rx) = flume::bounded(1); + pages.register(mine.clone(), mine_tx); + pages.register(other, other_tx); + + let mut batch = Batch::default(); + let flow = route( + message(RelayMessage::eose(mine)), + &pages, + &WatchRegistry::default(), + &mut batch, + ); + + assert_eq!(flow, Flow::Continue); + assert!(matches!( + mine_rx.try_recv().expect("a report").outcome, + Settled::Replayed + )); + assert!(other_rx.try_recv().is_err()); + } + + #[test] + fn a_refused_page_settles_its_relay_as_refused() { + let pages = PageRegistry::default(); + let id = SubscriptionId::new("concord-history-1"); + let (sender, receiver) = flume::bounded(1); + pages.register(id.clone(), sender); + + let mut batch = Batch::default(); + route( + message(RelayMessage::closed(id, "blocked: not allowed")), + &pages, + &WatchRegistry::default(), + &mut batch, + ); + + match receiver.try_recv().expect("a report").outcome { + Settled::Refused(reason) => assert!(reason.contains("blocked")), + other => panic!("expected a refusal, got {other:?}"), + } + } + + /// The SDK re-issues an `auth-required` REQ under the same id after AUTH, so + /// the page keeps waiting rather than writing the relay off. + #[test] + fn an_auth_required_close_settles_nothing() { + let pages = PageRegistry::default(); + let id = SubscriptionId::new("concord-history-1"); + let (sender, receiver) = flume::bounded(1); + pages.register(id.clone(), sender); + + let mut batch = Batch::default(); + route( + message(RelayMessage::closed( + id, + "auth-required: please authenticate", + )), + &pages, + &WatchRegistry::default(), + &mut batch, + ); + + assert!(receiver.try_recv().is_err()); + } + + #[test] + fn a_shutdown_stops_the_pump() { + let pages = PageRegistry::default(); + let mut batch = Batch::default(); + + assert_eq!( + route( + ClientNotification::Shutdown, + &pages, + &WatchRegistry::default(), + &mut batch + ), + Flow::Stop + ); + } +} diff --git a/crates/community/src/rekey.rs b/crates/community/src/rekey.rs new file mode 100644 index 00000000..787c3777 --- /dev/null +++ b/crates/community/src/rekey.rs @@ -0,0 +1,1202 @@ +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::sync::{Arc, Mutex, MutexGuard}; + +use anyhow::{Result, bail}; +use concord::cord01::{self, KIND_WRAP_EPHEMERAL, SealForm}; +use concord::cord04::roles::{CommunityRoles, Permissions}; +use concord::cord04::{self, AuthorityCitation}; +use concord::cord06::{ + self, Continuity, Refounding, RekeyScope, Rotation, RotationKey, RotationPlan, +}; +use concord::derive::{GroupKey, channel_rekey_group_key, epoch_key_commitment}; +use concord::state::{CommunityState, HeldKey}; +use concord::{ChannelId, CommunityId, Epoch}; +use nostr_sdk::prelude::*; +use state::UniversalSigner; + +use crate::sync::{self, PlaneKind}; + +/// Epochs ahead of a held epoch a rotation is looked for. +pub const REKEY_LOOKAHEAD: u64 = 8; +/// How much of what a relay stores a rekey watch replays. +const REKEY_REPLAY: usize = 200; + +/// One address the rekey watch asks a relay for, and what a wrap at it means. +#[derive(Debug, Clone)] +pub struct Watch { + pub address: PublicKey, + pub group: GroupKey, + pub scope: RekeyScope, + pub epoch: Epoch, +} + +/// The permission a rotation of `scope` is judged under, by whoever reads it and +/// by this client when it publishes one. +fn permissions(scope: RekeyScope) -> &'static [u64] { + match scope { + RekeyScope::Base => &[Permissions::BAN], + RekeyScope::Channel(_) => &[Permissions::MANAGE_CHANNELS, Permissions::BAN], + } +} + +/// Every address a community's rotations can arrive at. +/// +/// The base scope watches the epoch after *every* held root, not only the +/// current one: a rotation published while this client was away is read from +/// the root it stepped off, and the npub that minted an epoch is only knowable +/// from the rotation that minted it. +pub fn watches(state: &CommunityState) -> Result> { + let mut watches = Vec::new(); + let roots = state.roots(); + + for root in &roots { + let next = Epoch(root.epoch.0 + 1); + let group = cord06::rekey_group(RekeyScope::Base, &root.key, &state.id, next)?; + watches.push(Watch { + address: group.pk(), + group, + scope: RekeyScope::Base, + epoch: next, + }); + } + + for channel in &state.channels { + if !channel.private { + continue; + } + + for root in &roots { + for ahead in 1..=REKEY_LOOKAHEAD { + let epoch = Epoch(channel.epoch.0 + ahead); + let group = channel_rekey_group_key(&root.key, &channel.id, epoch)?; + watches.push(Watch { + address: group.pk(), + group, + scope: RekeyScope::Channel(channel.id), + epoch, + }); + } + } + } + + Ok(watches) +} + +pub fn watch_filter(watches: &[Watch]) -> Filter { + Filter::new() + .kind(Kind::GiftWrap) + .authors(watches.iter().map(|watch| watch.address)) + .limit(REKEY_REPLAY) +} + +/// The subscription carrying a community's rekey watch. +pub fn subscription_id(id: &CommunityId) -> SubscriptionId { + SubscriptionId::new(format!("rekey-{}", &id.to_hex()[..32])) +} + +/// Which community a rekey watch subscription belongs to. +#[derive(Default)] +pub struct WatchRegistry { + watches: Arc>>, +} + +impl Clone for WatchRegistry { + fn clone(&self) -> Self { + Self { + watches: Arc::clone(&self.watches), + } + } +} + +impl WatchRegistry { + pub fn register(&self, id: SubscriptionId, community: CommunityId) { + self.lock().insert(id, community); + } + + pub fn community_of(&self, id: &SubscriptionId) -> Option { + self.lock().get(id).copied() + } + + /// Forget every watch, because the account that installed them is gone. + pub fn clear(&self) { + self.lock().clear(); + } + + fn lock(&self) -> MutexGuard<'_, HashMap> { + match self.watches.lock() { + Ok(watches) => watches, + Err(poisoned) => poisoned.into_inner(), + } + } +} + +/// What one adoption pass learned, ready to be folded into the held state. +#[derive(Debug, Default)] +pub struct Adoptions { + pub base: Option, + pub channels: Vec, + /// Channels a complete rotation removed us from, with the epoch that did it. + pub cuts: Vec<(ChannelId, Epoch)>, + /// The base epoch a complete rotation excluded us at. + pub removed_at: Option, + pub stranded: bool, + /// The npubs whose rotations minted an epoch of this community. + pub refounders: BTreeSet, +} + +impl Adoptions { + pub fn is_empty(&self) -> bool { + self.base.is_none() + && self.channels.is_empty() + && self.cuts.is_empty() + && self.removed_at.is_none() + && !self.stranded + && self.refounders.is_empty() + } +} + +#[derive(Debug, Clone)] +pub struct BaseAdoption { + pub epoch: Epoch, + pub key: [u8; 32], + pub control_pk: Option, + /// The new Control Plane signing root, delivered to staff only. + pub control_root: Option<[u8; 32]>, + pub stepped: Vec, +} + +#[derive(Debug, Clone)] +pub struct ChannelAdoption { + pub channel: ChannelId, + pub epoch: Epoch, + pub key: [u8; 32], + /// The keys this rotation stepped off, newest first. + pub stepped: Vec, +} + +/// Read every rekey wrap the local database holds and adopt what is admissible. +pub async fn adopt( + client: &Client, + state: &CommunityState, + roles: &CommunityRoles, + signer: &UniversalSigner, + me: PublicKey, +) -> Result { + let watches = watches(state)?; + + let mut authors: BTreeSet = BTreeSet::new(); + for watch in &watches { + authors.insert(watch.address); + } + + if authors.is_empty() { + return Ok(Adoptions::default()); + } + + let wraps = client + .database() + .query(Filter::new().kind(Kind::GiftWrap).authors(authors)) + .await?; + + let mut chunks = Vec::new(); + let mut published: BTreeMap = BTreeMap::new(); + + for wrap in &wraps { + let Some(watch) = watches.iter().find(|watch| watch.address == wrap.pubkey) else { + continue; + }; + + let Ok(opened) = cord01::open_wrap(wrap, &watch.group) else { + continue; + }; + + let Ok(chunk) = cord06::parse_rekey_chunk(&opened) else { + continue; + }; + + if chunk.scope != watch.scope || chunk.new_epoch != watch.epoch { + continue; + } + + let at_ms = wrap.created_at.as_secs().saturating_mul(1000); + published + .entry(chunk.correlation()) + .and_modify(|held| *held = (*held).min(at_ms)) + .or_insert(at_ms); + + chunks.push(chunk); + } + + if chunks.is_empty() { + return Ok(Adoptions::default()); + } + + let rotations = cord06::collect_rotations(&chunks); + let mut adoptions = Adoptions::default(); + + let mut base = walk( + RekeyScope::Base, + permissions(RekeyScope::Base), + state.root_epoch, + state.community_root, + state, + roles, + signer, + me, + &rotations, + &published, + ) + .await?; + + let mut refounders: BTreeSet = base.refounders.clone(); + + for root in state.roots().into_iter().skip(1) { + let prior = walk( + RekeyScope::Base, + permissions(RekeyScope::Base), + root.epoch, + root.key, + state, + roles, + signer, + me, + &rotations, + &published, + ) + .await?; + + refounders.extend(prior.refounders.iter().copied()); + + let Some(adopted) = prior.adopted else { + continue; + }; + + if base + .adopted + .as_ref() + .is_none_or(|held| adopted.epoch > held.epoch) + { + base.adopted = Some(adopted); + } + } + + if let Some(step) = base.adopted { + adoptions.base = Some(BaseAdoption { + epoch: step.epoch, + key: step.key, + control_pk: step.control_pk, + control_root: step.control_root, + stepped: step.stepped, + }); + } + + adoptions.refounders = refounders; + adoptions.removed_at = base.removed_at; + adoptions.stranded = base.stranded; + + for channel in &state.channels { + if !channel.private { + continue; + } + + let Some((held_epoch, held_key)) = channel.current() else { + continue; + }; + + let step = walk( + RekeyScope::Channel(channel.id), + permissions(RekeyScope::Channel(channel.id)), + held_epoch, + held_key, + state, + roles, + signer, + me, + &rotations, + &published, + ) + .await?; + + if let Some(adopted) = step.adopted { + adoptions.channels.push(ChannelAdoption { + channel: channel.id, + epoch: adopted.epoch, + key: adopted.key, + stepped: adopted.stepped, + }); + } + + if let Some(epoch) = step.removed_at { + adoptions.cuts.push((channel.id, epoch)); + } + } + + Ok(adoptions) +} + +/// A rotation this client is asked to perform. +#[derive(Debug, Clone)] +pub struct Rewrite { + pub scope: RekeyScope, + /// Every member the new key must reach. + pub recipients: Vec, + /// The members it must not reach: whoever the rotation cuts off. + pub excluded: Vec, + /// The rotator's claim to rank, when it holds the grant to cite. + pub citation: Option, +} + +impl Rewrite { + /// Whether `rotator` may cut `excluded` off at all. + pub fn authorized( + &self, + roles: &CommunityRoles, + owner: &PublicKey, + rotator: &PublicKey, + ) -> bool { + permissions(self.scope) + .iter() + .any(|bits| cord06::rekey_authorized(roles, owner, rotator, *bits, &self.excluded)) + } +} + +/// Publish this client's own rotation of one scope to its next epoch. +#[allow(clippy::too_many_arguments)] +pub async fn rotate( + client: &Client, + state: &CommunityState, + roles: &CommunityRoles, + signer: &UniversalSigner, + me: PublicKey, + rewrite: &Rewrite, + at: Timestamp, +) -> Result { + if rewrite.excluded.contains(&me) { + bail!("a rotation cannot cut off the member who publishes it"); + } + + if !rewrite.recipients.contains(&me) { + bail!("a rotation must deliver its own rotator"); + } + + if rewrite + .excluded + .iter() + .any(|target| rewrite.recipients.contains(target)) + { + bail!("a rotation cannot both deliver to and cut off the same member"); + } + + let (held_epoch, held_key) = stepping_off(state, rewrite.scope)?; + let epoch = Epoch(held_epoch.0 + 1); + + if epoch.0 > cord06::MAX_REKEY_EPOCH { + bail!("epoch {} is past the rekey ceiling", epoch.0); + } + + let plan = cord06::plan_rotation(rewrite.scope, epoch)?; + let new_key = plan.new_key(); + let control_pk = match &plan { + RotationPlan::Base(refounding) => Some(refounding.signer(&state.id)?.pk().to_bytes()), + RotationPlan::Channel { .. } => None, + }; + + let mut blobs = Vec::with_capacity(rewrite.recipients.len()); + + for recipient in &rewrite.recipients { + // A refounded Control Plane's root reaches staff only. + let control_root = match &plan { + RotationPlan::Base(refounding) => roles + .is_staff(recipient, &state.owner) + .then_some(refounding.new_control_root), + RotationPlan::Channel { .. } => None, + }; + + blobs.push( + cord06::build_blob( + signer, + recipient, + rewrite.scope, + epoch, + &new_key, + control_pk.as_ref(), + control_root.as_ref(), + ) + .await?, + ); + } + + let group = cord06::rekey_group(rewrite.scope, &state.community_root, &state.id, epoch)?; + // A rotation that cuts somebody off is the severed kind. + let severed = !rewrite.excluded.is_empty(); + + let mut wraps = cord06::build_rekey_chunks( + signer, + &group, + rewrite.scope, + epoch, + held_epoch, + &epoch_key_commitment(held_epoch, &held_key), + &blobs, + rewrite.citation.as_ref(), + severed, + at.as_secs(), + ) + .await?; + + if let RotationPlan::Base(refounding) = &plan { + let carried = carry_heads(client, state, refounding, at).await?; + + if carried.is_empty() && !state.heads.is_empty() { + log::warn!("community: a refounding carried no settled head forward"); + } + + wraps.extend(carried); + } + + sync::publish_wraps(client, &wraps, &state.relays).await; + + log::debug!( + "community: rotated epoch {} to {} for {} member(s)", + held_epoch.0, + epoch.0, + blobs.len() + ); + + Ok(epoch) +} + +/// The epoch and key a rotation steps off. +fn stepping_off(state: &CommunityState, scope: RekeyScope) -> Result<(Epoch, [u8; 32])> { + match scope { + RekeyScope::Base => Ok((state.root_epoch, state.community_root)), + RekeyScope::Channel(channel) => state + .channels + .iter() + .find(|held| held.id == channel) + .and_then(|held| held.current()) + .ok_or_else(|| anyhow::anyhow!("no current key is held for {}", channel.to_hex())), + } +} + +/// Re-seal the settled control heads under a refounding's new groups. +/// +/// A rotation only mints keys. Unless the heads ride across with it, the new +/// epoch's Control Plane starts empty, and a member whose material never carried +/// the root we are stepping off folds no roles, metadata or banlist at all. +async fn carry_heads( + client: &Client, + state: &CommunityState, + refounding: &Refounding, + at: Timestamp, +) -> Result> { + if state.heads.is_empty() { + return Ok(Vec::new()); + } + + let planes = sync::planes(state)?; + let authors: BTreeSet = planes + .iter() + .filter(|plane| matches!(plane.kind, PlaneKind::Control(_))) + .map(|plane| plane.address) + .collect(); + + if authors.is_empty() { + return Ok(Vec::new()); + } + + let wraps = client + .database() + .query( + Filter::new() + .kinds([Kind::GiftWrap, Kind::Custom(KIND_WRAP_EPHEMERAL)]) + .authors(authors), + ) + .await?; + + let mut seals = Vec::new(); + let mut seen: BTreeSet = BTreeSet::new(); + + for wrap in &wraps { + let Some(plane) = planes.iter().find(|plane| { + matches!(plane.kind, PlaneKind::Control(_)) && plane.address == wrap.pubkey + }) else { + continue; + }; + + // A retired root reads only what was sealed before its rotation. + if !plane.accepts(wrap) { + continue; + } + + let Ok(opened) = + cord01::open_wrap_at(wrap, &plane.address, plane.group.conversation(), true) + else { + continue; + }; + + // Only a plaintext seal can be carried: `compact` re-signs nothing and + // re-wraps the edition the author already sealed. + if opened.seal_form != SealForm::Plaintext { + continue; + } + + let Ok(edition) = cord04::parse_edition(&opened.rumor) else { + continue; + }; + + let settled = state.heads.iter().any(|head| { + head.entity == edition.entity + && head.version == edition.version + && head.self_hash == edition.self_hash + }); + + // The same edition can be reachable twice once a head has been carried + // forward before: a head is published once per refounding. + if settled && seen.insert(opened.seal.id) { + seals.push(opened.seal); + } + } + + if seals.is_empty() { + return Ok(Vec::new()); + } + + Ok(cord06::compact( + &seals, + &refounding.read(&state.id)?, + &refounding.signer(&state.id)?, + at.as_secs(), + )?) +} + +/// The key grouping a rotation's chunks, recomputed from a collected rotation. +fn rotation_key(rotation: &Rotation) -> RotationKey { + ( + rotation.rotator.to_bytes(), + rotation.scope.id32(), + rotation.new_epoch.0, + rotation.prev_commit, + ) +} + +/// What one scope's walk found. +#[derive(Debug, Default)] +struct Step { + adopted: Option, + removed_at: Option, + stranded: bool, + /// The rotators of every base rotation this walk verified, which is who may + /// seed the Guestbook snapshot of an epoch they minted. + refounders: BTreeSet, +} + +#[derive(Debug, Clone)] +struct Adopted { + epoch: Epoch, + key: [u8; 32], + control_pk: Option, + control_root: Option<[u8; 32]>, + stepped: Vec, +} + +/// What one rotation offered this client, and when it was published. +#[derive(Debug, Clone, Copy)] +struct Delivery { + key: [u8; 32], + control_pk: Option, + control_root: Option<[u8; 32]>, + at_ms: u64, +} + +/// Walk a scope's rotations forward, one epoch at a time, off the key held. +#[allow(clippy::too_many_arguments)] +async fn walk( + scope: RekeyScope, + permissions: &[u64], + mut held_epoch: Epoch, + mut held_key: [u8; 32], + state: &CommunityState, + roles: &CommunityRoles, + signer: &UniversalSigner, + me: PublicKey, + rotations: &[Rotation], + published: &BTreeMap, +) -> Result { + let mut step = Step::default(); + let mut stepped: Vec = Vec::new(); + let ceiling = held_epoch.0 + REKEY_LOOKAHEAD; + + loop { + let target = Epoch(held_epoch.0 + 1); + + if target.0 > ceiling { + break; + } + + let candidates: Vec<&Rotation> = rotations + .iter() + .filter(|rotation| { + rotation.scope == scope + && rotation.new_epoch == target + && rotation.is_complete() + && !state.banned.contains(&rotation.rotator) + && permissions + .iter() + .any(|bits| roles.is_authorized(&rotation.rotator, &state.owner, *bits)) + }) + .collect(); + + if candidates.is_empty() { + break; + } + + let mut delivery: Option = None; + let mut addressed = false; + + for rotation in candidates + .iter() + .filter(|rotation| rotation.continuity(held_epoch, &held_key) == Continuity::Extends) + { + // Who minted an epoch is proven by the rotation itself, not by a blob: + // a rotation is only a candidate after continuity against a key we + // hold, so its rotator minted this epoch whether or not it addressed + // us. A member who joined on a stale bundle never held the epochs + // between, and the snapshot that seeds them is only honored on this + // npub's authority (CORD-02 §5). + if scope == RekeyScope::Base { + step.refounders.insert(rotation.rotator); + } + + let at_ms = published + .get(&rotation_key(rotation)) + .copied() + .unwrap_or_default(); + + let blobs = cord06::find_my_blobs( + &rotation.blobs, + &rotation.rotator, + &me, + scope, + rotation.new_epoch, + ) + .collect::>(); + + if blobs.is_empty() { + continue; + } + + addressed = true; + + for blob in blobs { + let Ok(delivered) = cord06::open_blob( + signer, + &rotation.rotator, + scope, + rotation.new_epoch, + blob, + &state.id, + ) + .await + else { + continue; + }; + + let control_pk = delivered + .control_pk + .and_then(|bytes| PublicKey::from_slice(&bytes).ok()); + let raced = delivery.map(|held| held.key); + + // Racing rotations converge on the lowest new key. + if raced.is_none_or(|raced| delivered.new_key < raced) { + delivery = Some(Delivery { + key: delivered.new_key, + control_pk, + control_root: delivered.control_root, + at_ms, + }); + } else if let Some(held) = delivery.as_mut() { + held.at_ms = held.at_ms.min(at_ms); + } + } + } + + if let Some(delivered) = delivery { + stepped.insert( + 0, + HeldKey { + epoch: held_epoch, + key: held_key, + retired_at: Some(Timestamp::from_secs(delivered.at_ms / 1000)), + }, + ); + + held_epoch = target; + held_key = delivered.key; + + step.adopted = Some(Adopted { + epoch: target, + key: delivered.key, + control_pk: delivered.control_pk, + control_root: delivered.control_root, + stepped: stepped.clone(), + }); + + continue; + } + + if addressed { + break; + } + + let judged: Vec<&&Rotation> = candidates + .iter() + .filter(|rotation| rotation.continuity(held_epoch, &held_key) != Continuity::Fork) + .collect(); + + let published_at = |rotation: &&Rotation| { + published + .get(&rotation_key(rotation)) + .copied() + .unwrap_or_default() + }; + + if judged.iter().any(|rotation| { + published_at(rotation) >= state.added_at_ms + && permissions.iter().any(|bits| { + roles.can_act_on_member(&rotation.rotator, &state.owner, &me, *bits) + }) + }) { + step.removed_at = Some(target); + } else if scope == RekeyScope::Base + && judged + .iter() + .any(|rotation| published_at(rotation) < state.added_at_ms) + { + step.stranded = true; + } + + break; + } + + Ok(step) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use concord::cord04::roles::CommunityRoles; + use concord::cord06::{RekeyBlob, build_blob, build_rekey_chunks}; + use concord::derive::{ + base_rekey_group_key, channel_rekey_group_key, control_signer_group_key, + epoch_key_commitment, + }; + use concord::state::{ChannelKeyRef, HeldRoot}; + use nostr_memory::MemoryDatabase; + + use super::*; + + const AT_MS: u64 = 1_700_000_000_000; + const ROOT: [u8; 32] = [0x55; 32]; + const NEW_ROOT: [u8; 32] = [0x66; 32]; + const NEWER_ROOT: [u8; 32] = [0x77; 32]; + const CHANNEL_KEY: [u8; 32] = [0x07; 32]; + const NEW_CHANNEL_KEY: [u8; 32] = [0x08; 32]; + + fn client() -> Client { + ClientBuilder::default() + .database(MemoryDatabase::unbounded()) + .build() + } + + fn state(owner: PublicKey, id: CommunityId, channel: ChannelId) -> CommunityState { + CommunityState { + id, + name: Some("Room".to_owned()), + owner, + owner_salt: [0x01; 32], + community_root: ROOT, + root_epoch: Epoch(0), + control_root: None, + control_pks: BTreeMap::from([(0, owner)]), + channels: vec![ChannelKeyRef { + id: channel, + name: "staff".to_owned(), + private: true, + epoch: Epoch(0), + key: Some(CHANNEL_KEY), + priors: Vec::new(), + }], + relays: Vec::new(), + heads: Vec::new(), + banned: BTreeSet::new(), + cursors: BTreeMap::new(), + held_roots: Vec::new(), + channel_cuts: BTreeMap::new(), + refounders: BTreeSet::new(), + removed_at: None, + stranded: false, + dissolved: false, + added_at_ms: AT_MS, + } + } + + async fn store(client: &Client, wraps: &[Event]) { + for wrap in wraps { + client.database().save_event(wrap).await.expect("saves"); + } + } + + fn base_chunks( + owner: &Keys, + id: &CommunityId, + prior_commit: &[u8; 32], + blobs: &[RekeyBlob], + ) -> Vec { + let group = base_rekey_group_key(&ROOT, id, Epoch(1)).expect("derives"); + + smol::block_on(build_rekey_chunks( + owner, + &group, + RekeyScope::Base, + Epoch(1), + Epoch(0), + prior_commit, + blobs, + None, + false, + AT_MS / 1000, + )) + .expect("builds") + } + + fn channel_chunks( + owner: &Keys, + channel: &ChannelId, + prior_commit: &[u8; 32], + blobs: &[RekeyBlob], + ) -> Vec { + let scope = RekeyScope::Channel(*channel); + let group = channel_rekey_group_key(&ROOT, channel, Epoch(1)).expect("derives"); + + smol::block_on(build_rekey_chunks( + owner, + &group, + scope, + Epoch(1), + Epoch(0), + prior_commit, + blobs, + None, + false, + AT_MS / 1000, + )) + .expect("builds") + } + + fn blob_for( + rotator: &Keys, + recipient: &Keys, + scope: RekeyScope, + key: [u8; 32], + control_pk: Option<&[u8; 32]>, + ) -> RekeyBlob { + smol::block_on(build_blob( + rotator, + &recipient.public_key(), + scope, + Epoch(1), + &key, + control_pk, + None, + )) + .expect("builds") + } + + #[test] + fn a_complete_base_rotation_is_adopted_and_retires_the_prior_root() { + smol::block_on(async { + let client = client(); + let owner = Keys::generate(); + let me = Keys::generate(); + let id = CommunityId::from_bytes([0x42; 32]); + let channel = ChannelId::from_bytes([0x9c; 32]); + let state = state(owner.public_key(), id, channel); + + let control_root = [0xAB; 32]; + let control_pk = control_signer_group_key(&control_root, &id, Epoch(1)) + .expect("derives") + .pk() + .to_bytes(); + + let blob = blob_for(&owner, &me, RekeyScope::Base, NEW_ROOT, Some(&control_pk)); + let wraps = base_chunks(&owner, &id, &epoch_key_commitment(Epoch(0), &ROOT), &[blob]); + store(&client, &wraps).await; + + let signer = UniversalSigner::new(me.clone()); + let roles = CommunityRoles::default(); + let adoptions = adopt(&client, &state, &roles, &signer, me.public_key()) + .await + .expect("reads"); + + let base = adoptions.base.expect("adopted"); + assert_eq!(base.epoch, Epoch(1)); + assert_eq!(base.key, NEW_ROOT); + assert_eq!(base.control_pk, PublicKey::from_slice(&control_pk).ok()); + assert_eq!(base.stepped.len(), 1); + assert_eq!(base.stepped[0].epoch, Epoch(0)); + assert_eq!(base.stepped[0].key, ROOT); + assert_eq!( + base.stepped[0].retired_at, + Some(Timestamp::from_secs(AT_MS / 1000)) + ); + assert!(adoptions.removed_at.is_none()); + assert!(!adoptions.stranded); + }); + } + + /// A complete base rotation off a retained root is adopted from it, which + /// is the only way its epoch's refounder is ever learned: a Guestbook + /// snapshot seeds members on that npub's authority alone (CORD-02 §5). + #[test] + fn a_rotation_past_a_retained_root_is_adopted_and_names_its_refounder() { + smol::block_on(async { + let client = client(); + let owner = Keys::generate(); + let me = Keys::generate(); + let id = CommunityId::from_bytes([0x42; 32]); + let channel = ChannelId::from_bytes([0x9c; 32]); + + // At epoch 1, with the root it stepped off retained. + let mut state = state(owner.public_key(), id, channel); + state.root_epoch = Epoch(1); + state.community_root = NEW_ROOT; + state.held_roots = vec![HeldRoot { + epoch: Epoch(0), + key: ROOT, + control_pk: None, + retired_at: None, + }]; + + let blob = smol::block_on(build_blob( + &owner, + &me.public_key(), + RekeyScope::Base, + Epoch(2), + &NEWER_ROOT, + None, + None, + )) + .expect("builds"); + let group = base_rekey_group_key(&NEW_ROOT, &id, Epoch(2)).expect("derives"); + let wraps = smol::block_on(build_rekey_chunks( + &owner, + &group, + RekeyScope::Base, + Epoch(2), + Epoch(1), + &epoch_key_commitment(Epoch(1), &NEW_ROOT), + &[blob], + None, + false, + AT_MS / 1000, + )) + .expect("builds"); + store(&client, &wraps).await; + + let signer = UniversalSigner::new(me.clone()); + let adoptions = adopt( + &client, + &state, + &CommunityRoles::default(), + &signer, + me.public_key(), + ) + .await + .expect("reads"); + + let base = adoptions.base.expect("adopted"); + assert_eq!(base.epoch, Epoch(2)); + assert_eq!(base.key, NEWER_ROOT); + assert_eq!(base.stepped.len(), 1, "the root it stepped off"); + assert_eq!(base.stepped[0].epoch, Epoch(1)); + assert!(adoptions.refounders.contains(&owner.public_key())); + }); + } + + /// A rotation that addresses somebody else still names the npub who minted the + /// epoch: continuity against a key we hold is what proves it. That authority is + /// what a Guestbook snapshot is honored on, so a member who joined on a stale + /// bundle can still read the roster the Refounding seeded. + #[test] + fn a_rotation_that_delivered_us_no_key_still_names_its_refounder() { + smol::block_on(async { + let client = client(); + let owner = Keys::generate(); + let me = Keys::generate(); + let other = Keys::generate(); + let id = CommunityId::from_bytes([0x42; 32]); + let channel = ChannelId::from_bytes([0x9c; 32]); + + // At epoch 2, holding the root the rotation stepped off but never + // having walked it: the shape a stale join bundle produces. + let mut state = state(owner.public_key(), id, channel); + state.root_epoch = Epoch(2); + state.community_root = NEWER_ROOT; + state.held_roots = vec![HeldRoot { + epoch: Epoch(1), + key: NEW_ROOT, + control_pk: None, + retired_at: None, + }]; + + let blob = smol::block_on(build_blob( + &owner, + &other.public_key(), + RekeyScope::Base, + Epoch(2), + &NEWER_ROOT, + None, + None, + )) + .expect("builds"); + let group = base_rekey_group_key(&NEW_ROOT, &id, Epoch(2)).expect("derives"); + let wraps = smol::block_on(build_rekey_chunks( + &owner, + &group, + RekeyScope::Base, + Epoch(2), + Epoch(1), + &epoch_key_commitment(Epoch(1), &NEW_ROOT), + &[blob], + None, + false, + AT_MS / 1000, + )) + .expect("builds"); + store(&client, &wraps).await; + + let signer = UniversalSigner::new(me.clone()); + let adoptions = adopt( + &client, + &state, + &CommunityRoles::default(), + &signer, + me.public_key(), + ) + .await + .expect("reads"); + + assert!(adoptions.base.is_none(), "nothing to adopt"); + assert!(!adoptions.is_empty(), "the minter is still learned"); + assert!(adoptions.refounders.contains(&owner.public_key())); + }); + } + + /// A rotation off a key we do not hold is a fork: adoptable by nobody here. + #[test] + fn a_rotation_that_does_not_extend_the_held_key_is_never_adopted() { + smol::block_on(async { + let client = client(); + let owner = Keys::generate(); + let me = Keys::generate(); + let id = CommunityId::from_bytes([0x42; 32]); + let channel = ChannelId::from_bytes([0x9c; 32]); + let state = state(owner.public_key(), id, channel); + + let blob = blob_for(&owner, &me, RekeyScope::Base, NEW_ROOT, None); + let wraps = base_chunks( + &owner, + &id, + &epoch_key_commitment(Epoch(0), &[0x99; 32]), + &[blob], + ); + store(&client, &wraps).await; + + let signer = UniversalSigner::new(me.clone()); + let roles = CommunityRoles::default(); + let adoptions = adopt(&client, &state, &roles, &signer, me.public_key()) + .await + .expect("reads"); + + assert!(adoptions.is_empty()); + }); + } + + /// Every chunk held, none carrying my blob, from a rotator who outranks me + /// and published after I joined: I was excluded, not stranded. + #[test] + fn a_blobless_rotation_from_an_outranking_rotator_removes_the_member() { + smol::block_on(async { + let client = client(); + let owner = Keys::generate(); + let me = Keys::generate(); + let other = Keys::generate(); + let id = CommunityId::from_bytes([0x42; 32]); + let channel = ChannelId::from_bytes([0x9c; 32]); + let state = state(owner.public_key(), id, channel); + + let blob = blob_for(&owner, &other, RekeyScope::Base, NEW_ROOT, None); + let wraps = base_chunks(&owner, &id, &epoch_key_commitment(Epoch(0), &ROOT), &[blob]); + store(&client, &wraps).await; + + let signer = UniversalSigner::new(me.clone()); + let roles = CommunityRoles::default(); + let adoptions = adopt(&client, &state, &roles, &signer, me.public_key()) + .await + .expect("reads"); + + assert!(adoptions.base.is_none()); + assert_eq!(adoptions.removed_at, Some(Epoch(1))); + assert!(!adoptions.stranded); + }); + } + + #[test] + fn a_channel_rotation_replaces_the_key_and_keeps_the_prior() { + smol::block_on(async { + let client = client(); + let owner = Keys::generate(); + let me = Keys::generate(); + let id = CommunityId::from_bytes([0x42; 32]); + let channel = ChannelId::from_bytes([0x9c; 32]); + let state = state(owner.public_key(), id, channel); + + let blob = blob_for( + &owner, + &me, + RekeyScope::Channel(channel), + NEW_CHANNEL_KEY, + None, + ); + let wraps = channel_chunks( + &owner, + &channel, + &epoch_key_commitment(Epoch(0), &CHANNEL_KEY), + &[blob], + ); + store(&client, &wraps).await; + + let signer = UniversalSigner::new(me.clone()); + let roles = CommunityRoles::default(); + let adoptions = adopt(&client, &state, &roles, &signer, me.public_key()) + .await + .expect("reads"); + + assert_eq!(adoptions.channels.len(), 1); + let adopted = &adoptions.channels[0]; + assert_eq!(adopted.channel, channel); + assert_eq!(adopted.epoch, Epoch(1)); + assert_eq!(adopted.key, NEW_CHANNEL_KEY); + assert_eq!(adopted.stepped.len(), 1); + assert_eq!(adopted.stepped[0].epoch, Epoch(0)); + assert_eq!(adopted.stepped[0].key, CHANNEL_KEY); + assert_eq!( + adopted.stepped[0].retired_at, + Some(Timestamp::from_secs(AT_MS / 1000)) + ); + assert!(adoptions.cuts.is_empty()); + }); + } +} diff --git a/crates/community/src/sync.rs b/crates/community/src/sync.rs new file mode 100644 index 00000000..f8d944c7 --- /dev/null +++ b/crates/community/src/sync.rs @@ -0,0 +1,1661 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use concord::cord01::KIND_WRAP_EPHEMERAL; +use concord::cord02::list::{CommunityList, JoinMaterial, KIND_COMMUNITY_LIST}; +use concord::cord02::{self, ControlFold, ImageRef}; +use concord::cord04::AuthorityCitation; +use concord::cord04::roles::{Permissions, citation_ok}; +use concord::derive::{ + channel_group_key, control_group_key, control_signer_group_key, guestbook_group_key, +}; +use concord::state::{CommunityState, HeldKey, HeldRoot, list_entry}; +use concord::{ChannelId, CommunityId, Epoch, GroupKey, decode_hex_32}; +use gpui::AsyncApp; +use nostr_sdk::prelude::*; +use state::UniversalSigner; + +use crate::cache::{self, Observed}; +use crate::history::{CURSOR_OVERLAP, Window}; + +/// How much of what a relay stores a cold subscription replays per relay. +const LIVE_REPLAY: usize = 500; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PlaneKind { + Control(Epoch), + Guestbook, + Channel(ChannelId, Epoch), +} + +#[derive(Debug, Clone)] +pub struct Plane { + pub kind: PlaneKind, + /// The wrap's author: the control signer for Control, the group's own key otherwise. + pub address: PublicKey, + pub group: GroupKey, + /// When the rotation that retired this plane's key published. + pub retired_at: Option, +} + +impl Plane { + /// Whether a wrap sealed under this plane is still inside its key's life. + pub fn accepts(&self, wrap: &Event) -> bool { + self.retired_at + .is_none_or(|retired| wrap.created_at <= retired) + } +} + +pub fn planes(state: &CommunityState) -> Result> { + let mut planes = Vec::new(); + let roots = state.roots(); + + for (epoch, address) in &state.control_pks { + let epoch = Epoch(*epoch); + // An epoch's Control Plane reads under the root that was current then, + // so a rotation that kept a floor for the prior root republishes here. + let root = roots + .iter() + .find(|root| root.epoch == epoch) + .copied() + .unwrap_or(HeldRoot { + epoch, + key: state.community_root, + control_pk: None, + retired_at: None, + }); + + let group = control_group_key(&root.key, &state.id, epoch)?; + planes.push(Plane { + kind: PlaneKind::Control(epoch), + address: *address, + group, + retired_at: root.retired_at, + }); + } + + 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, + retired_at: None, + }); + } + + for root in &roots { + let group = guestbook_group_key(&root.key, &state.id, root.epoch)?; + planes.push(Plane { + kind: PlaneKind::Guestbook, + address: group.pk(), + group, + retired_at: root.retired_at, + }); + } + + for channel in &state.channels { + for held in state.held_keys(&channel.id) { + let group = channel_group_key(&held.key, &channel.id, held.epoch)?; + planes.push(Plane { + kind: PlaneKind::Channel(channel.id, held.epoch), + address: group.pk(), + group, + retired_at: held.retired_at, + }); + } + } + + // A rotation can re-derive an address the current root already produced. + // + // A duplicate author would only repeat a filter, so keep the set unique. + let mut seen = BTreeSet::new(); + planes.retain(|plane| seen.insert(plane.address)); + + Ok(planes) +} + +pub fn plane_filter(planes: &[Plane]) -> Filter { + Filter::new() + .kinds([Kind::GiftWrap, Kind::Custom(KIND_WRAP_EPHEMERAL)]) + .authors(planes.iter().map(|plane| plane.address)) +} + +pub fn live_filter(planes: &[Plane], window: Window) -> Filter { + let mut filter = plane_filter(planes); + + if let Some(until) = window.until { + filter = filter.until(until); + } + + if let Some(since) = window.since { + filter = filter.since(since); + } + + filter.limit(LIVE_REPLAY) +} + +/// The window a community's standing subscription opens with. +pub fn live_window(state: &CommunityState, now: Timestamp) -> Window { + let floor = state + .channels + .iter() + .filter_map(|channel| state.cursors.get(&channel.id)?.newest) + .min(); + + match floor { + Some(floor) => Window { + since: Some(floor.min(now) - CURSOR_OVERLAP), + until: None, + }, + None => Window::default(), + } +} + +/// The subscription id carrying a community's planes. +pub fn subscription_id(id: &CommunityId) -> SubscriptionId { + SubscriptionId::new(id.to_hex()) +} + +pub fn community_of(subscription_id: &SubscriptionId) -> Option { + subscription_id.as_str().parse().ok() +} + +/// Download and decrypt a community icon into a content-addressed cache file. +pub async fn resolve_image(image: &ImageRef, cx: &AsyncApp) -> Result { + let url = Url::parse(&image.url).context("community image url")?; + state::download_and_decrypt_to_cache(&url, &image.key, &image.nonce, &image.hash, cx).await +} + +#[derive(Debug, Clone)] +pub struct Snapshot { + pub state: CommunityState, + pub control: ControlFold, + pub members: BTreeSet, + /// Wraps the store holds per channel that no held key can open. + pub unreadable: BTreeMap, +} + +pub async fn create( + client: &Client, + signer: &S, + metadata: &cord02::CommunityMetadata, +) -> Result +where + S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, +{ + let at_secs = Timestamp::now().as_secs(); + let genesis = cord02::genesis(signer, metadata, at_secs).await?; + let id = genesis.identity.community_id; + + let read = control_group_key(&genesis.community_root, &id, cord02::ROOT_EPOCH)?; + let address = control_signer_group_key(&genesis.control_root, &id, cord02::ROOT_EPOCH)?.pk(); + + let mut editions = Vec::with_capacity(genesis.wraps.len()); + + for wrap in &genesis.wraps { + editions.push(cord02::open_edition(wrap, &read, &address, true)?); + } + + let state = CommunityState::from_genesis(&genesis, &editions, at_secs.saturating_mul(1000))?; + cache::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. +pub(crate) async fn publish_wraps(client: &Client, wraps: &[Event], relays: &[RelayUrl]) { + connect_relays(client, relays).await; + + for wrap in wraps { + publish_wrap(client, wrap, relays).await; + } +} + +/// Bring the community's relays into the pool before anything is sent through them. +pub(crate) async fn connect_relays(client: &Client, relays: &[RelayUrl]) { + for url in relays { + if let Err(error) = client.add_relay(url).and_connect().await { + log::warn!("community: failed to add relay {url}: {error}"); + } + } +} + +/// Best-effort publication of a single wrap to the community's relays. +pub(crate) async fn publish_wrap(client: &Client, wrap: &Event, relays: &[RelayUrl]) { + 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: {} relay(s) rejected {}", + output.failed.len(), + wrap.id + ), + Err(error) => log::warn!("community: publishing {} failed: {error}", wrap.id), + } +} + +async fn record_membership( + 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 = 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> { + 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"; + +pub fn list_subscription_id() -> SubscriptionId { + SubscriptionId::new(LIST_SUBSCRIPTION) +} + +pub fn is_list_subscription(id: &SubscriptionId) -> bool { + id.as_str() == LIST_SUBSCRIPTION +} + +/// Subscribes to the account's community list. +pub async fn subscribe_list(client: &Client, self_pk: PublicKey) -> Result<()> { + let id = list_subscription_id(); + client.unsubscribe(&id).await?; + + let filter = Filter::new() + .kind(Kind::Custom(KIND_COMMUNITY_LIST)) + .author(self_pk); + + let output = client + .subscribe(ReqTarget::auto(vec![filter])) + .with_id(id) + .await?; + + if !output.failed.is_empty() { + log::warn!( + "community list: {} relay(s) rejected the subscription", + output.failed.len() + ); + } + + Ok(()) +} + +/// Discovers the current account's communities: every live membership the List +/// carries, plus any locally-held membership the List does not mention. +/// +/// A held membership is dropped only when the List carries a tombstone at least +/// as new as it, because absence from the List is never a fact (§8). +pub async fn load( + client: &Client, + signer: &UniversalSigner, + self_pk: PublicKey, +) -> Result> { + let list = match load_list(client, signer, self_pk).await? { + Some(list) => list, + None => return cache::load_states(client).await, + }; + + let mut held: BTreeMap = cache::load_states(client) + .await? + .into_iter() + .map(|state| (state.id, state)) + .collect(); + + held.retain(|id, state| !retired(&list, id, state.added_at_ms)); + + for entry in &list.entries { + if !list.is_live(&entry.community_id) { + continue; + } + + let fresh = match CommunityState::from_join_material(&entry.current, entry.added_at) { + Ok(fresh) => fresh, + Err(error) => { + log::warn!( + "ignoring unreadable community {} from the list: {error}", + entry.community_id.to_hex() + ); + continue; + } + }; + + let mut state = match held.remove(&entry.community_id) { + Some(materialized) => refresh(materialized, fresh), + None => fresh, + }; + + retain_join_root(&mut state, &entry.seed); + adopt_list_material(&mut state, &entry.seed); + adopt_list_material(&mut state, &entry.current); + + cache::save_state(client, &state).await?; + held.insert(entry.community_id, state); + } + + Ok(held.into_values().collect()) +} + +/// Take the snapshot authority and retained roots a List entry names. +fn adopt_list_material(state: &mut CommunityState, material: &JoinMaterial) { + if material.root_epoch.0 > 0 + && let Some(refounder) = material.refounder() + { + state.refounders.insert(refounder); + } + + for root in material.held_roots() { + if root.epoch >= state.root_epoch || root.epoch.0 == 0 { + continue; + } + + if let Some(refounder) = root.refounder { + state.refounders.insert(refounder); + } + + retain_root(state, root.epoch, root.key, root.control_pk); + } +} + +fn retain_join_root(state: &mut CommunityState, seed: &JoinMaterial) { + if seed.root_epoch >= state.root_epoch { + return; + } + + let Ok(key) = decode_hex_32(&seed.community_root) else { + return; + }; + + retain_root(state, seed.root_epoch, key, seed.control_pk); +} + +/// Retain a root the community has rotated past, newest first. +fn retain_root( + state: &mut CommunityState, + epoch: Epoch, + key: [u8; 32], + control_pk: Option, +) { + if state.held_roots.iter().any(|root| root.epoch == epoch) { + return; + } + + let at = state + .held_roots + .iter() + .position(|root| root.epoch < epoch) + .unwrap_or(state.held_roots.len()); + + state.held_roots.insert( + at, + HeldRoot { + epoch, + key, + control_pk, + retired_at: None, + }, + ); +} + +fn retired(list: &CommunityList, id: &CommunityId, added_at_ms: u64) -> bool { + list.tombstones + .iter() + .find(|tombstone| tombstone.community_id == *id) + .is_some_and(|tombstone| tombstone.removed_at >= added_at_ms) +} + +fn refresh(mut held: CommunityState, fresh: CommunityState) -> CommunityState { + if fresh.root_epoch > held.root_epoch { + let (epoch, key) = (held.root_epoch, held.community_root); + let control_pk = held.control_pks.get(&epoch.0).copied(); + retain_root(&mut held, epoch, key, control_pk); + } + + held.owner = fresh.owner; + held.owner_salt = fresh.owner_salt; + held.community_root = fresh.community_root; + held.root_epoch = fresh.root_epoch; + held.added_at_ms = fresh.added_at_ms; + + if fresh.control_root.is_some() { + 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); + } + + held.relays = fresh.relays; + + for channel in fresh.channels { + let cut = held.channel_cuts.get(&channel.id).copied(); + + match held.channels.iter_mut().find(|held| held.id == channel.id) { + Some(held) => { + held.name = channel.name; + + if cut.is_some_and(|cut| channel.epoch <= cut) { + continue; + } + + if channel.private { + held.private = true; + + if let Some(key) = channel.key + && (held.key != Some(key) || held.epoch != channel.epoch) + { + // The key being superseded still reads everything + // written under it, so it is retained, never overwritten. + if let Some((epoch, previous)) = held.current() + && !held.priors.iter().any(|prior| prior.epoch == epoch) + { + held.priors.push(HeldKey { + epoch, + key: previous, + retired_at: None, + }); + } + + held.key = Some(key); + held.epoch = channel.epoch; + } + } + } + None => { + if cut.is_none() { + held.channels.push(channel); + } + } + } + } + + held +} + +/// The newest held copy of each fragment, keyed by its `d` index. +async fn newest_fragments(client: &Client, self_pk: PublicKey) -> Result> { + let filter = Filter::new() + .kind(Kind::Custom(KIND_COMMUNITY_LIST)) + .author(self_pk); + + let mut newest: BTreeMap = BTreeMap::new(); + + for event in client.database().query(filter).await? { + let Ok(index) = cord02::list::fragment_index(&event) else { + continue; + }; + + match newest.get(&index) { + Some(existing) if existing.created_at >= event.created_at => {} + _ => { + newest.insert(index, event); + } + } + } + + Ok(newest) +} + +/// Every fragment of the account's list in the local database, merged. +async fn load_list( + client: &Client, + signer: &S, + self_pk: PublicKey, +) -> Result> +where + S: AsyncGetPublicKey + AsyncNip44 + ?Sized, +{ + let mut merged: Option = None; + + 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 { + Some(held) => cord02::list::merge(held, list), + None => list, + }); + } + Err(error) => { + log::warn!("ignoring unreadable community list {}: {error}", event.id); + } + } + } + + Ok(merged) +} + +/// Rebuilds a community from the wraps already in the local database. +pub async fn fold(client: &Client, state: &CommunityState) -> Result> { + let planes = planes(state)?; + + if planes.is_empty() { + return Ok(None); + } + + let wraps = client.database().query(plane_filter(&planes)).await?; + + let mut editions = Vec::new(); + let mut observed: BTreeMap = BTreeMap::new(); + let mut guestbook_rumors = Vec::new(); + let mut unreadable: BTreeMap = BTreeMap::new(); + let mut cached: BTreeMap> = BTreeMap::new(); + + for plane in &planes { + if let PlaneKind::Channel(channel, _) = plane.kind + && !cached.contains_key(&channel) + { + cached.insert(channel, cache::wrapper_index(client, &channel).await?); + } + } + + for wrap in &wraps { + let Some(plane) = planes.iter().find(|plane| plane.address == wrap.pubkey) else { + continue; + }; + + match plane.kind { + PlaneKind::Control(_) => { + // A retired root reads only what was sealed before its rotation. + if !plane.accepts(wrap) { + continue; + } + + if let Ok(edition) = cord02::open_edition(wrap, &plane.group, &plane.address, true) + { + observe( + &mut observed, + edition.author, + wrap.created_at.as_secs().saturating_mul(1000), + ); + editions.push(edition); + } + } + PlaneKind::Guestbook => { + if !plane.accepts(wrap) { + continue; + } + + if let Ok((_, rumor)) = cord02::guestbook::open(wrap, &plane.group) { + observe(&mut observed, rumor.author, rumor.at_ms); + guestbook_rumors.push(rumor); + } + } + PlaneKind::Channel(channel, epoch) => { + if let Some(row) = cached.get(&channel).and_then(|index| index.get(&wrap.id)) { + observe(&mut observed, row.author, row.at_ms); + continue; + } + + let opened = if plane.accepts(wrap) { + concord::cord03::open(wrap, &plane.group, &channel, epoch).ok() + } else { + None + }; + + match opened { + Some((opened, rumor)) => { + cache::cache_rumor(client, &channel, &opened).await?; + observe(&mut observed, rumor.author, rumor.at_ms); + } + None => *unreadable.entry(channel).or_default() += 1, + } + } + } + } + + if editions.is_empty() { + return Ok(None); + } + + let control = cord02::fold_control( + &state.owner, + &state.id, + &editions, + &state.floors(), + &state.banned, + ); + + let granted: BTreeSet = control + .roles + .grants() + .filter(|grant| !grant.role_ids.is_empty()) + .map(|grant| grant.member) + .collect(); + + let floors = state.floors(); + let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&AuthorityCitation>| { + citation_ok(&state.owner, &state.id, actor, citation, &floors) + && control + .roles + .can_act_on_member(actor, &state.owner, target, Permissions::KICK) + }; + + let now_ms = Timestamp::now().as_secs().saturating_mul(1000); + let coalesced = + cord02::guestbook::coalesce(&guestbook_rumors, now_ms, &state.refounders, can_kick); + + let mut members = cord02::guestbook::complete_memberlist( + &coalesced, + &observed, + &granted, + &control.banned, + &BTreeMap::new(), + ); + + // The roster has no grant for the owner, so membership is stated here. + members.insert(state.owner); + + let mut state = state.clone(); + state.apply_fold(&control); + cache::save_state(client, &state).await?; + + Ok(Some(Snapshot { + state, + control, + members, + unreadable, + })) +} + +fn observe(observed: &mut BTreeMap, author: PublicKey, at_ms: u64) { + observed + .entry(author) + .and_modify(|seen| *seen = (*seen).max(at_ms)) + .or_insert(at_ms); +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use nostr_memory::MemoryDatabase; + + use super::*; + + fn client() -> Client { + ClientBuilder::default() + .database(MemoryDatabase::unbounded()) + .build() + } + + #[test] + fn planes_address_the_control_guestbook_and_every_readable_channel() { + let owner = Keys::generate().public_key(); + let control_pk = Keys::generate().public_key(); + let general = ChannelId::from_bytes([0x9c; 32]); + let staff = ChannelId::from_bytes([0x9d; 32]); + + 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], + root_epoch: Epoch(0), + control_root: None, + control_pks: BTreeMap::from([(0, control_pk)]), + channels: vec![ + concord::state::ChannelKeyRef { + id: general, + name: "general".to_owned(), + private: false, + epoch: Epoch(0), + key: None, + priors: Vec::new(), + }, + concord::state::ChannelKeyRef { + id: staff, + name: "staff".to_owned(), + private: true, + epoch: Epoch(0), + key: Some([0x04; 32]), + priors: Vec::new(), + }, + concord::state::ChannelKeyRef { + id: ChannelId::from_bytes([0x9e; 32]), + name: "locked".to_owned(), + private: true, + epoch: Epoch(0), + key: None, + priors: Vec::new(), + }, + ], + relays: vec![RelayUrl::parse("wss://relay.example").expect("a url")], + heads: Vec::new(), + banned: BTreeSet::new(), + cursors: BTreeMap::new(), + held_roots: Vec::new(), + channel_cuts: BTreeMap::new(), + refounders: BTreeSet::new(), + removed_at: None, + stranded: false, + dissolved: false, + added_at_ms: 0, + }; + + let planes = planes(&state).expect("planes"); + + assert_eq!(planes.len(), 4); + assert!(planes.iter().any(|plane| plane.address == control_pk)); + assert!( + planes + .iter() + .any(|plane| matches!(plane.kind, PlaneKind::Guestbook)) + ); + assert!( + planes + .iter() + .any(|plane| matches!(plane.kind, PlaneKind::Channel(id, _) if id == general)) + ); + assert!( + planes + .iter() + .any(|plane| matches!(plane.kind, PlaneKind::Channel(id, _) if id == staff)), + "a private channel whose key is held is subscribed" + ); + + let filter = plane_filter(&planes); + let addresses: BTreeSet = planes.iter().map(|plane| plane.address).collect(); + assert_eq!(filter.authors, Some(addresses)); + assert_eq!( + filter.kinds, + Some(BTreeSet::from([ + Kind::GiftWrap, + Kind::Custom(KIND_WRAP_EPHEMERAL) + ])), + "the standing subscription asks for both wrap kinds" + ); + } + + /// A cold subscription asks wide; a warm one resumes at the oldest held + /// cursor, minus the overlap, so no channel's new region is skipped. A floor + /// the local clock has not reached is a peer's stamp and is clamped, or the + /// subscription would ask from a region that is still in the future. + #[test] + fn the_live_window_is_wide_cold_and_resumes_at_the_oldest_cursor_warm() { + let mut state = held( + CommunityId::from_bytes([0x42; 32]), + Keys::generate().public_key(), + ); + let channel = state.channels[0].id; + let now = Timestamp::now(); + + assert_eq!(live_window(&state, now), Window::default()); + + state.cursors.insert( + channel, + concord::state::ChannelCursor { + newest: Some(Timestamp::from_secs(2_000_000)), + oldest: Some(Timestamp::from_secs(1_000)), + exhausted: false, + }, + ); + assert_eq!( + live_window(&state, now), + Window { + since: Some(Timestamp::from_secs(2_000_000) - CURSOR_OVERLAP), + until: None, + } + ); + + let ahead = now + Duration::from_secs(3_600); + state.cursors.insert( + channel, + concord::state::ChannelCursor { + newest: Some(ahead), + oldest: Some(Timestamp::from_secs(1_000)), + exhausted: false, + }, + ); + assert_eq!( + live_window(&state, now), + Window { + since: Some(now - CURSOR_OVERLAP), + until: None, + }, + "a cursor stamped in the future must not open the REQ ahead of now" + ); + } + + fn metadata(name: &str) -> cord02::CommunityMetadata { + cord02::CommunityMetadata { + name: name.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::state::ChannelKeyRef { + id: ChannelId::from_bytes([0x9c; 32]), + name: "general".to_owned(), + private: false, + epoch: Epoch(0), + key: None, + priors: Vec::new(), + }], + relays: Vec::new(), + heads: Vec::new(), + banned: BTreeSet::new(), + cursors: BTreeMap::new(), + held_roots: Vec::new(), + channel_cuts: BTreeMap::new(), + refounders: BTreeSet::new(), + removed_at: None, + stranded: false, + 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(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"); + } + + /// A Refounding snapshot seeds members this client has never seen publish — + /// but only on the authority of the npub whose rotation minted the epoch it + /// seeds (CORD-02 §5), which is why the refounder is recorded at all. + #[test] + fn a_refounders_snapshot_seeds_the_members_it_names() { + smol::block_on(async { + let client = client(); + let keys = Keys::generate(); + let signer = UniversalSigner::new(keys.clone()); + let refounder = Keys::generate(); + let quiet = Keys::generate().public_key(); + + let created = create(&client, &signer, &metadata("coop")) + .await + .expect("creates"); + + // Past genesis, and past the rotation whose refounder this client + // verified: genesis mints no epoch by rotation, so it has no + // snapshot authority at all. + let mut state = created; + state.root_epoch = Epoch(1); + state.refounders.insert(refounder.public_key()); + + let group = guestbook_group_key(&state.community_root, &state.id, state.root_epoch) + .expect("a guestbook plane"); + let chunks = cord02::guestbook::build_snapshot_chunks( + refounder.public_key(), + &[quiet], + [0x77u8; 32], + state.added_at_ms, + ); + + for chunk in &chunks { + let (wrap, _) = cord02::guestbook::seal_rumor(chunk, &group, &refounder) + .await + .expect("seals"); + client.database().save_event(&wrap).await.expect("saves"); + } + + let seeded = fold(&client, &state) + .await + .expect("folds") + .expect("a control plane"); + assert!( + seeded.members.contains(&quiet), + "the snapshot's members are the roster" + ); + + // Without the rotation that minted the epoch, the same chunks seed + // nobody: an unverifiable snapshot is not an authority. + let mut unverified = state.clone(); + unverified.refounders.clear(); + + let folded = fold(&client, &unverified) + .await + .expect("folds") + .expect("a control plane"); + assert!(!folded.members.contains(&quiet)); + }); + } + + /// A List entry that names the npub whose Refounding minted its epoch hands + /// this client the authority its Guestbook snapshot is honored on, which is + /// how a device that never held the rotation still reads the seeded roster. + /// (What the fold then does with that authority is the neighboring test.) + #[test] + fn a_list_entrys_refounder_becomes_the_snapshot_authority() { + smol::block_on(async { + let client = client(); + let keys = Keys::generate(); + let signer = UniversalSigner::new(keys.clone()); + let refounder = Keys::generate(); + + let joined = held( + CommunityId::from_bytes([0x42; 32]), + Keys::generate().public_key(), + ); + let mut rotated = joined.clone(); + rotated.root_epoch = Epoch(2); + rotated.community_root = [0x44; 32]; + + let mut entry = list_entry(&rotated, "coop"); + entry.seed = list_entry(&joined, "coop").seed; + entry.added_at = joined.added_at_ms; + entry.current.extra.insert( + "refounder".to_owned(), + serde_json::Value::String(refounder.public_key().to_hex()), + ); + + let list = CommunityList::default().joined(entry); + store_fragment(&client, &signer, &list).await; + + let loaded = load(&client, &signer, keys.public_key()) + .await + .expect("loads"); + let state = loaded + .iter() + .find(|state| state.id == joined.id) + .expect("loaded"); + + assert_eq!(state.root_epoch, Epoch(2)); + assert!(state.refounders.contains(&refounder.public_key())); + }); + } + + /// A List that has rotated on keeps the root of our join: the material is + /// the community as we were given it, and the planes that root addressed + /// stay readable only while it is held. + #[test] + fn a_list_that_moved_the_root_on_retains_the_root_of_our_join() { + smol::block_on(async { + let client = client(); + let keys = Keys::generate(); + let signer = UniversalSigner::new(keys.clone()); + + let joined = held( + CommunityId::from_bytes([0x42; 32]), + Keys::generate().public_key(), + ); + let mut rotated = joined.clone(); + rotated.root_epoch = Epoch(2); + rotated.community_root = [0x44; 32]; + + // The entry as a community that rotated twice since would carry it: + // the join's material as the seed, the current material as current. + let mut entry = list_entry(&rotated, "coop"); + entry.seed = list_entry(&joined, "coop").seed; + entry.added_at = joined.added_at_ms; + + let list = CommunityList::default().joined(entry); + store_fragment(&client, &signer, &list).await; + + let loaded = load(&client, &signer, keys.public_key()) + .await + .expect("loads"); + let state = loaded + .iter() + .find(|state| state.id == joined.id) + .expect("loaded"); + + assert_eq!(state.root_epoch, Epoch(2)); + assert_eq!(state.community_root, [0x44; 32]); + assert_eq!( + state + .held_roots + .iter() + .map(|root| (root.epoch, root.key)) + .collect::>(), + vec![(joined.root_epoch, joined.community_root)] + ); + }); + } + + /// 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. + #[test] + fn creating_a_community_persists_a_state_that_subscribes_and_folds() { + smol::block_on(async { + let client = client(); + let keys = Keys::generate(); + let signer = UniversalSigner::new(keys.clone()); + + let created = create(&client, &signer, &metadata("coop")) + .await + .expect("creates"); + + let loaded = load(&client, &signer, keys.public_key()) + .await + .expect("loads"); + assert_eq!(loaded, vec![created.clone()]); + + // The plane filter must address the genesis wraps, or a fold would + // read a plane nothing is ever published on. + let planes = planes(&created).expect("planes"); + let wraps = client + .database() + .query(plane_filter(&planes)) + .await + .expect("queries"); + assert_eq!(wraps.len(), created.heads.len()); + assert!(wraps.iter().all(|wrap| wrap.kind == Kind::GiftWrap)); + + let snapshot = fold(&client, &created) + .await + .expect("folds") + .expect("a control plane"); + assert_eq!(snapshot.state.channels.len(), 1); + assert_eq!(snapshot.members, BTreeSet::from([keys.public_key()])); + assert_eq!( + snapshot + .control + .community + .as_ref() + .map(|metadata| metadata.name.as_str()), + Some("coop") + ); + + // An inbound control edit made by the owner folds over the created state. + let community_head = created + .heads + .iter() + .find(|head| head.entity == *created.id.as_bytes()) + .expect("a community head"); + let writer = cord02::ControlWriter { + author: created.owner, + read: control_group_key(&created.community_root, &created.id, cord02::ROOT_EPOCH) + .expect("a reading key"), + signer: control_signer_group_key( + &created.control_root.expect("a control root"), + &created.id, + cord02::ROOT_EPOCH, + ) + .expect("a signing key"), + }; + + let (wrap, _) = writer + .set_community_metadata( + &keys, + &created.id, + &metadata("coop two"), + Some(community_head), + None, + Timestamp::now().as_secs() + 1, + ) + .await + .expect("publishes"); + client.database().save_event(&wrap).await.expect("saves"); + + let updated = fold(&client, &created) + .await + .expect("folds") + .expect("a control plane"); + assert_eq!( + updated + .control + .community + .as_ref() + .map(|metadata| metadata.name.as_str()), + Some("coop two") + ); + }); + } + + /// The reference counts anyone seen publishing anywhere, and a control edit + /// is the one publication that leaves no other trace: an npub that has + /// never joined a channel and never touched the Guestbook exists in no + /// other plane, so a fold that reads only those reads them as a stranger. + #[test] + fn a_control_editions_author_is_observed_as_a_member() { + smol::block_on(async { + let client = client(); + let keys = Keys::generate(); + let signer = UniversalSigner::new(keys.clone()); + let editor = Keys::generate(); + + let created = create(&client, &signer, &metadata("coop")) + .await + .expect("creates"); + + let community_head = created + .heads + .iter() + .find(|head| head.entity == *created.id.as_bytes()) + .expect("a community head"); + let writer = cord02::ControlWriter { + author: editor.public_key(), + read: control_group_key(&created.community_root, &created.id, cord02::ROOT_EPOCH) + .expect("a reading key"), + signer: control_signer_group_key( + &created.control_root.expect("a control root"), + &created.id, + cord02::ROOT_EPOCH, + ) + .expect("a signing key"), + }; + + // The editor holds no grant, so the edit is inert: it supersedes + // nothing. It is still a publication by that npub. + let (wrap, _) = writer + .set_community_metadata( + &editor, + &created.id, + &metadata("coop two"), + Some(community_head), + None, + Timestamp::now().as_secs() + 1, + ) + .await + .expect("publishes"); + client.database().save_event(&wrap).await.expect("saves"); + + let snapshot = fold(&client, &created) + .await + .expect("folds") + .expect("a control plane"); + + assert!( + snapshot.members.contains(&editor.public_key()), + "an edition's author is a member the fold has seen publishing" + ); + assert_eq!( + snapshot + .control + .community + .as_ref() + .map(|metadata| metadata.name.as_str()), + Some("coop"), + "an unauthorized edit still changes nothing" + ); + }); + } + + /// 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(list_entry(&listed, "coop")); + store_fragment(&client, &signer, &list).await; + + assert!( + cache::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!( + cache::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(list_entry(&listed, "listed")); + store_fragment(&client, &signer, &list).await; + cache::save_state(&client, &local).await.expect("saves"); + + let loaded = load(&client, &signer, keys.public_key()) + .await + .expect("loads"); + let ids: BTreeSet = 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(), + ); + cache::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)); + } + + fn event_at(at_ms: u64) -> Event { + EventBuilder::new(Kind::TextNote, "wrap") + .custom_created_at(Timestamp::from_secs(at_ms / 1000)) + .finalize(&Keys::generate()) + .expect("signs") + } + + /// A key a rotation stepped off still reads its own history, but nothing + /// sealed after the rotation published it. + #[test] + fn a_superseded_key_reads_only_up_to_the_rotation_that_retired_it() { + let channel = ChannelId::from_bytes([0x9c; 32]); + let mut state = held( + CommunityId::from_bytes([0x42; 32]), + Keys::generate().public_key(), + ); + + state.channels = vec![concord::state::ChannelKeyRef { + id: channel, + name: "staff".to_owned(), + private: true, + epoch: Epoch(1), + key: Some([0x08; 32]), + priors: vec![HeldKey { + epoch: Epoch(0), + key: [0x07; 32], + retired_at: Some(Timestamp::from_secs(1_000)), + }], + }]; + + let planes = planes(&state).expect("planes"); + let retired = planes + .iter() + .find(|plane| plane.kind == PlaneKind::Channel(channel, Epoch(0))) + .expect("the retired epoch keeps a plane"); + + assert_eq!(retired.retired_at, Some(Timestamp::from_secs(1_000))); + assert!(retired.accepts(&event_at(1_000_000))); + assert!(!retired.accepts(&event_at(1_001_000))); + } + + /// A public channel reads one plane per held root epoch, and the plane for + /// the CURRENT root is the one the community writes to now. + /// + /// A Refounding moves the community root to a new epoch and every public + /// channel's plane with it (CORD-03 §1: a public channel's secret is the + /// community root, at the root epoch). Material the channel carries from + /// before the rotation names the old epoch, so a reader that trusts it asks + /// a retired address forever: the channel shows everything written before + /// the rotation and nothing after, while the control and guestbook planes — + /// which derive from the roots directly — stay current. + #[test] + fn a_refounding_moves_a_public_channels_plane_to_the_new_root_epoch() { + smol::block_on(async { + let client = client(); + let keys = Keys::generate(); + let signer = UniversalSigner::new(keys.clone()); + + let mut state = create(&client, &signer, &metadata("coop")) + .await + .expect("creates"); + let channel = state.channels[0].id; + let rotated_at = Timestamp::now(); + let prior_root = state.community_root; + + // A Refounding: the root moves to epoch 1, the prior one is held for + // history, and the channel's own material still names epoch 0. + state.community_root = [0x5a; 32]; + state.root_epoch = Epoch(1); + state.held_roots = vec![HeldRoot { + epoch: Epoch(0), + key: prior_root, + control_pk: None, + retired_at: Some(rotated_at), + }]; + + assert_eq!(state.channels[0].epoch, Epoch(0)); + + let current = channel_group_key(&state.community_root, &channel, state.root_epoch) + .expect("derives"); + let plane = planes(&state) + .expect("planes") + .into_iter() + .find(|plane| plane.address == current.pk()) + .expect("the current root's channel plane is subscribed and read"); + + assert_eq!(plane.kind, PlaneKind::Channel(channel, Epoch(1))); + assert!( + plane.accepts( + &EventBuilder::new(Kind::GiftWrap, "") + .finalize(plane.group.keys()) + .expect("signs") + ) + ); + + // And a message sealed at the current plane folds into the channel, + // which is what the timeline reads. + let rumor = concord::cord03::build_message( + keys.public_key(), + &channel, + state.root_epoch, + "after the refounding", + None, + rotated_at.as_secs().saturating_mul(1000), + None, + ); + let (wrap, _) = concord::cord03::seal_rumor(&rumor, &plane.group, &signer, false) + .await + .expect("seals"); + client.database().save_event(&wrap).await.expect("saves"); + + let snapshot = fold(&client, &state) + .await + .expect("folds") + .expect("a control plane"); + assert_eq!(snapshot.unreadable.get(&channel).copied(), None); + + let cached = cache::query_rumors( + &client, + &channel, + None, + 10, + Some(&concord::cord03::ROW_KINDS), + ) + .await + .expect("reads"); + + assert_eq!(cached.len(), 1, "a message at the new epoch reads back"); + }); + } + + /// A wrap a subscription delivered lands in the database and nowhere else. + /// The fold is what turns it into a cached rumor, which is the only thing + /// the timeline reads, so a live message depends on this step. + #[test] + fn a_fold_caches_a_channel_wrap_a_relay_delivered() { + smol::block_on(async { + let client = client(); + let keys = Keys::generate(); + let signer = UniversalSigner::new(keys.clone()); + + let created = create(&client, &signer, &metadata("coop")) + .await + .expect("creates"); + + let (channel, epoch, plane) = planes(&created) + .expect("planes") + .into_iter() + .find_map(|plane| match plane.kind { + PlaneKind::Channel(channel, epoch) => Some((channel, epoch, plane)), + _ => None, + }) + .expect("a channel plane"); + + let rumor = concord::cord03::build_message( + keys.public_key(), + &channel, + epoch, + "a live message", + None, + 1_700_000_000_000, + None, + ); + let (wrap, _) = concord::cord03::seal_rumor(&rumor, &plane.group, &signer, false) + .await + .expect("seals"); + + // What the SDK does with a wrap a standing subscription delivered. + client.database().save_event(&wrap).await.expect("saves"); + + let snapshot = fold(&client, &created) + .await + .expect("folds") + .expect("a control plane"); + + assert_eq!(snapshot.unreadable.get(&channel).copied(), None); + + let cached = cache::query_rumors( + &client, + &channel, + None, + 10, + Some(&concord::cord03::ROW_KINDS), + ) + .await + .expect("reads"); + + assert_eq!(cached.len(), 1, "the delivered wrap is cached as a rumor"); + assert_eq!(cached[0].pubkey, keys.public_key()); + }); + } + + /// A wrap addressed to a held channel plane that will not open is counted, so + /// the panel can tell a quiet room from one whose history it cannot read. + #[test] + fn a_fold_counts_the_channel_wraps_it_cannot_open() { + smol::block_on(async { + let client = client(); + let keys = Keys::generate(); + let signer = UniversalSigner::new(keys.clone()); + + let created = create(&client, &signer, &metadata("coop")) + .await + .expect("creates"); + + let (channel, plane) = planes(&created) + .expect("planes") + .into_iter() + .find_map(|plane| match plane.kind { + PlaneKind::Channel(channel, _) => Some((channel, plane)), + _ => None, + }) + .expect("a channel plane"); + + // Sealed to the channel's own address, but not a seal at all, so no + // held key opens it. + let junk = EventBuilder::new(Kind::GiftWrap, "not a seal") + .custom_created_at(Timestamp::from_secs(1_700_000_000)) + .finalize(plane.group.keys()) + .expect("signs"); + client.database().save_event(&junk).await.expect("saves"); + + let snapshot = fold(&client, &created) + .await + .expect("folds") + .expect("a control plane"); + + assert_eq!(snapshot.unreadable.get(&channel).copied(), Some(1)); + }); + } +} diff --git a/crates/community_ui/Cargo.toml b/crates/community_ui/Cargo.toml new file mode 100644 index 00000000..3facb2ad --- /dev/null +++ b/crates/community_ui/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "community_ui" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +community = { path = "../community" } +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 diff --git a/crates/community_ui/src/lib.rs b/crates/community_ui/src/lib.rs new file mode 100644 index 00000000..524d532b --- /dev/null +++ b/crates/community_ui/src/lib.rs @@ -0,0 +1,792 @@ +use std::collections::HashMap; +use std::fmt; + +use anyhow::Result; +use community::{ + ChannelId, ChatMessage, Community, CommunityEvent, Epoch, Intent, LOAD_OLDER_PAGES, + TIMELINE_PAGE, Timeline, +}; +use gpui::prelude::FluentBuilder; +use gpui::{ + AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, FollowMode, + IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, Render, SharedString, + Styled, Subscription, Task, WeakEntity, Window, div, list, px, +}; +use nostr_sdk::prelude::EventId; +use smallvec::{SmallVec, smallvec}; +use theme::ActiveTheme; +use ui::avatar::Avatar; +use ui::button::{Button, ButtonVariants}; +use ui::dock::{Panel, PanelEvent}; +use ui::input::{InputEvent, Textarea, TextareaState}; +use ui::notification::Notification; +use ui::scroll::Scrollbar; +use ui::{Disableable, IconName, Sizable, WindowExtension, h_flex, v_flex}; + +mod message; + +/// How near the top row a scroll has to come before the panel splices older history in. +const LOAD_OLDER_THRESHOLD: usize = 20; +/// A repeat message within this window keeps its run, so it carries no avatar or name. +const RUN_WINDOW_MS: u64 = 300_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Notice { + Stranded, + Removed(Epoch), + ChannelRemoved(Epoch), + MissingKey(Epoch), + Unreachable, + Unreadable(usize), +} + +impl fmt::Display for Notice { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Notice::Stranded => formatter.write_str( + "This invite is stale, the community has rotated past the epoch it names", + ), + Notice::Removed(epoch) => write!( + formatter, + "You were removed from this community at epoch {}. Its history stays readable", + epoch.0 + ), + Notice::ChannelRemoved(epoch) => write!( + formatter, + "A rotation removed you from this channel at epoch {}", + epoch.0 + ), + Notice::MissingKey(epoch) => write!( + formatter, + "Messages here can't be read yet, this channel's key for epoch {} is missing", + epoch.0 + ), + Notice::Unreachable => formatter.write_str("Couldn't reach the community's relays"), + Notice::Unreadable(1) => { + formatter.write_str("1 message here can't be read yet, no key we hold opens it") + } + Notice::Unreadable(count) => write!( + formatter, + "{count} messages here can't be read yet, no key we hold opens them" + ), + } + } +} + +impl Notice { + fn writable(self) -> bool { + matches!(self, Notice::Unreachable | Notice::Unreadable(_)) + } +} + +pub fn init( + community: Entity, + window: &mut Window, + cx: &mut App, +) -> Entity { + cx.new(|cx| CommunityPanel::new(community, window, cx)) +} + +/// Community Panel +pub struct CommunityPanel { + id: SharedString, + focus_handle: FocusHandle, + /// Community + community: WeakEntity, + /// The selected channel + channel: Option, + /// The selected channel's timeline (oldest first) + rows: Vec, + /// Whether the store holds rows older than `rows` + has_more: bool, + /// A round or a page read is in flight + loading: bool, + /// Message list state + list_state: ListState, + /// Message input state + input: Entity, + /// Spawned reads and publishes, cancelled when the panel closes + tasks: SmallVec<[Task>; 4]>, + /// Event subscriptions + _subscriptions: SmallVec<[Subscription; 2]>, +} + +impl CommunityPanel { + pub fn new(community: Entity, window: &mut Window, cx: &mut Context) -> Self { + let (id, name, channel) = { + let community = community.read(cx); + + ( + SharedString::from(format!("community-{}", community.id().to_hex())), + community.name(), + community.active_channel(), + ) + }; + + let input = cx.new(|cx| { + TextareaState::new(window, cx) + .placeholder(format!("Message {name}")) + .auto_grow(1, 20) + .clean_on_escape() + }); + + let mut subscriptions = smallvec![]; + + subscriptions.push( + cx.subscribe_in(&input, window, |this, _input, event, window, cx| { + if let InputEvent::PressEnter { .. } = event { + this.send(window, cx); + } + }), + ); + + subscriptions.push(cx.subscribe_in( + &community, + window, + |_this, _community, event, window, cx| { + match event { + CommunityEvent::Updated(_) + | CommunityEvent::Unreadable(_) + | CommunityEvent::Failed(_) => { + cx.defer_in(window, |this, window, cx| this.reload(window, cx)); + } + CommunityEvent::Channel(..) => { + cx.defer_in(window, |this, window, cx| this.load(window, cx)); + } + CommunityEvent::Error(error) => { + window.push_notification( + Notification::error(error.clone()).autohide(false), + cx, + ); + } + CommunityEvent::Open(_) | CommunityEvent::Close(_) => {} + }; + }, + )); + + let panel = Self { + id, + focus_handle: cx.focus_handle(), + community: community.downgrade(), + channel, + rows: Vec::new(), + has_more: false, + loading: false, + list_state: ListState::new(0, ListAlignment::Bottom, px(1024.)), + input, + tasks: smallvec![], + _subscriptions: subscriptions, + }; + + panel.list_state.set_follow_mode(FollowMode::Tail); + panel.list_state.set_scroll_handler(cx.listener( + |this, event: &ListScrollEvent, window, cx| { + if event.visible_range.start <= LOAD_OLDER_THRESHOLD { + this.load_older(window, cx); + } + }, + )); + + cx.defer_in(window, |this, window, cx| { + this.load(window, cx); + }); + + panel + } + + /// The channel to show, following the community's selection. + fn resolve_channel(&mut self, cx: &App) -> Option { + let channel = self + .community + .read_with(cx, |community, _cx| community.active_channel()) + .ok() + .flatten(); + + if channel != self.channel { + self.channel = channel; + self.rows.clear(); + self.has_more = false; + self.loading = false; + self.list_state.reset(1); + } + + channel + } + + /// The list's item count: the load-older row, then every message row. + fn item_count(&self) -> usize { + self.rows.len() + 1 + } + + /// A timeline read, `before_ms` exclusive, or `None` for the newest rows. + fn read( + &self, + channel: ChannelId, + before_ms: Option, + cx: &App, + ) -> Option>> { + self.community + .read_with(cx, |community, cx| { + community.timeline(&channel, before_ms, TIMELINE_PAGE, cx) + }) + .ok() + } + + /// A channel round, or `None` once the community is gone. + fn sync( + &self, + channel: ChannelId, + intent: Intent, + cx: &mut App, + ) -> Option>> { + self.community + .update(cx, |community, cx| { + community.sync_channel(&channel, intent, cx) + }) + .ok() + } + + /// Paint the selected channel's cache, then catch it up from the relays. + fn load(&mut self, window: &mut Window, cx: &mut Context) { + let Some(channel) = self.resolve_channel(cx) else { + return; + }; + + self.reload(window, cx); + + // Opening a channel twice in a breath asks the relays once. + if self.due(channel, cx) { + self.round(channel, Intent::CatchUp, window, cx); + } + } + + /// Whether the community would actually round `channel`, or just serve it. + fn due(&self, channel: ChannelId, cx: &App) -> bool { + self.community + .read_with(cx, |community, _cx| community.due(&channel)) + .unwrap_or(true) + } + + /// Why the room is not showing messages, when it is not simply empty. + fn notice(&self, cx: &App) -> Option { + let channel = self.channel?; + + self.community + .read_with(cx, |community, _cx| { + if community.stranded() { + return Some(Notice::Stranded); + } + + if let Some(epoch) = community.removed_at() { + return Some(Notice::Removed(epoch)); + } + + if let Some(epoch) = community.channel_removed_at(&channel) { + return Some(Notice::ChannelRemoved(epoch)); + } + + if let Some(epoch) = community.missing_key(&channel) { + return Some(Notice::MissingKey(epoch)); + } + + if community + .progress(&channel) + .is_some_and(|progress| progress.failed && progress.errors > 0) + { + return Some(Notice::Unreachable); + } + + let unreadable = community.unreadable(&channel); + + (unreadable > 0).then_some(Notice::Unreadable(unreadable)) + }) + .ok() + .flatten() + } + + /// Run a round for `channel`, its completion re-reads the timeline. + fn round( + &mut self, + channel: ChannelId, + intent: Intent, + window: &mut Window, + cx: &mut Context, + ) { + let Some(round) = self.sync(channel, intent, cx) else { + return; + }; + + self.loading = true; + cx.notify(); + + let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| { + let result = round.await; + + this.update_in(cx, |this, window, cx| { + this.loading = false; + + if let Err(error) = result { + window.push_notification( + Notification::error(error.to_string()).autohide(false), + cx, + ); + } + })?; + + Ok(()) + }); + + self.tasks.push(task); + } + + /// Run the newest round again after a relay failure. + fn retry(&mut self, window: &mut Window, cx: &mut Context) { + let Some(channel) = self.channel else { + return; + }; + + self.round(channel, Intent::CatchUp, window, cx); + } + + /// Read the newest page and fold it into what is on screen. + fn reload(&mut self, window: &mut Window, cx: &mut Context) { + let Some(channel) = self.resolve_channel(cx) else { + return; + }; + + let Some(timeline) = self.read(channel, None, cx) else { + return; + }; + + let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| { + match timeline.await { + Ok(timeline) => this.update(cx, |this, cx| this.apply(channel, timeline, cx))?, + Err(error) => { + this.update_in(cx, |_this, window, cx| { + window.push_notification( + Notification::error(error.to_string()).autohide(false), + cx, + ); + })?; + } + } + + Ok(()) + }); + + self.tasks.push(task); + } + + /// Splice the page of history above the oldest row on screen. + fn load_older(&mut self, window: &mut Window, cx: &mut Context) { + if self.loading || !self.has_more { + return; + } + + let Some(channel) = self.channel else { + return; + }; + + let Some(before_ms) = self + .rows + .first() + .map(|message| message.at_ms.saturating_sub(1)) + else { + return; + }; + + let Some(page) = self.read(channel, Some(before_ms), cx) else { + return; + }; + + self.loading = true; + + let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| { + let timeline = match page.await { + Ok(timeline) => timeline, + Err(error) => { + this.update_in(cx, |this, window, cx| { + this.loading = false; + window.push_notification( + Notification::error(error.to_string()).autohide(false), + cx, + ); + })?; + return Ok(()); + } + }; + + let swept = this.update(cx, |this, cx| { + this.prepend(channel, timeline, cx); + !this.has_more + })?; + + if !swept { + this.update(cx, |this, _cx| this.loading = false)?; + return Ok(()); + } + + let round = this.update(cx, |this, cx| { + this.sync( + channel, + Intent::Older { + pages: LOAD_OLDER_PAGES, + }, + cx, + ) + })?; + + let Some(round) = round else { + this.update(cx, |this, _cx| this.loading = false)?; + return Ok(()); + }; + + match round.await { + Ok(_) => { + let page = + this.update(cx, |this, cx| this.read(channel, Some(before_ms), cx))?; + + if let Some(page) = page { + match page.await { + Ok(timeline) => { + this.update(cx, |this, cx| this.prepend(channel, timeline, cx))? + } + Err(error) => this.update_in(cx, |_this, window, cx| { + window.push_notification( + Notification::error(error.to_string()).autohide(false), + cx, + ); + })?, + } + } + + this.update(cx, |this, _cx| this.loading = false)?; + } + Err(error) => { + this.update_in(cx, |this, window, cx| { + this.loading = false; + window.push_notification( + Notification::error(error.to_string()).autohide(false), + cx, + ); + })?; + } + } + + Ok(()) + }); + + self.tasks.push(task); + } + + /// Fold a freshly read window into the rows on screen. + fn apply(&mut self, channel: ChannelId, timeline: Timeline, cx: &mut Context) { + if self.channel != Some(channel) { + return; + } + + let Timeline { messages, has_more } = timeline; + + let connected = self + .rows + .last() + .is_some_and(|last| messages.iter().any(|message| message.id == last.id)); + + if !connected { + self.rows = messages; + self.has_more = has_more; + self.list_state.reset(self.item_count()); + cx.notify(); + return; + } + + self.merge(messages); + cx.notify(); + } + + /// Splice older rows in above what is on screen. + fn prepend(&mut self, channel: ChannelId, timeline: Timeline, cx: &mut Context) { + if self.channel != Some(channel) { + return; + } + + self.has_more = timeline.has_more; + self.merge(timeline.messages); + cx.notify(); + } + + /// Fold read rows into what is on screen, keeping the list in time order. + fn merge(&mut self, messages: Vec) { + let mut shown: HashMap = self + .rows + .iter() + .enumerate() + .map(|(ix, message)| (message.id, ix)) + .collect(); + + let mut fresh = Vec::new(); + + for message in messages { + match shown.get(&message.id).copied() { + Some(ix) => self.rows[ix] = message, + None => fresh.push(message), + } + } + + for message in fresh { + if shown.contains_key(&message.id) { + continue; + } + + let at = self + .rows + .partition_point(|row| (row.at_ms, row.id) <= (message.at_ms, message.id)); + + shown.insert(message.id, at); + self.rows.insert(at, message); + self.list_state.splice(at + 1..at + 1, 1); + } + } + + fn send(&mut self, window: &mut Window, cx: &mut Context) { + let content = self.input.read(cx).value().trim().to_owned(); + + if content.is_empty() { + window.push_notification("Cannot send an empty message", cx); + return; + } + + let Some(channel) = self.resolve_channel(cx) else { + return; + }; + + if let Some(notice) = self.notice(cx).filter(|notice| !notice.writable()) { + window.push_notification(Notification::error(notice.to_string()).autohide(false), cx); + return; + } + + let Ok(send) = self.community.read_with(cx, |community, cx| { + community.send(&channel, &content, None, cx) + }) else { + return; + }; + + let Some(send) = send else { + window.push_notification(Notification::error("Failed to send the message"), cx); + return; + }; + + self.input.update(cx, |input, cx| { + input.set_value("", window, cx); + }); + + let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| { + match send.await { + Ok(_) => { + this.update_in(cx, |this, window, cx| this.reload(window, cx))?; + } + Err(error) => { + this.update_in(cx, |_this, window, cx| { + window.push_notification( + Notification::error(error.to_string()).autohide(false), + cx, + ); + })?; + } + } + + Ok(()) + }); + + self.tasks.push(task); + } + + /// The honest reason this room has nothing to show, with a way out of it. + fn render_notice(&self, notice: Notice, cx: &mut Context) -> AnyElement { + h_flex() + .w_full() + .justify_center() + .items_center() + .gap_2() + .px_3() + .py_2() + .text_sm() + .text_color(cx.theme().text_placeholder) + .child(notice.to_string()) + .when(notice == Notice::Unreachable, |this| { + this.child( + Button::new("retry-round") + .label("Retry") + .ghost() + .small() + .loading(self.loading) + .on_click(cx.listener(|this, _event, window, cx| this.retry(window, cx))), + ) + }) + .into_any_element() + } + + /// The row at index 0: the affordance that pages older history in. + fn render_older(&self, cx: &mut Context) -> AnyElement { + if !self.has_more { + return div().into_any_element(); + } + + h_flex() + .w_full() + .justify_center() + .py_2() + .child( + Button::new("load-older") + .label(if self.loading { + "Loading earlier messages…" + } else { + "Load earlier messages" + }) + .ghost() + .small() + .loading(self.loading) + .on_click(cx.listener(|this, _event, window, cx| this.load_older(window, cx))), + ) + .into_any_element() + } + + /// Whether the row at `index` opens a run from one author. + fn opens_run(&self, index: usize) -> bool { + let Some(current) = self.rows.get(index) else { + return true; + }; + + let Some(previous) = index.checked_sub(1).and_then(|index| self.rows.get(index)) else { + return true; + }; + + current.author != previous.author + || current.at_ms.saturating_sub(previous.at_ms) > RUN_WINDOW_MS + } + + fn render_message( + &mut self, + ix: usize, + _window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + if ix == 0 { + return self.render_older(cx); + } + + let Some(message) = self.rows.get(ix - 1) else { + return div().into_any_element(); + }; + + let show_author = self.opens_run(ix - 1); + + message::render(ix, message, show_author, cx) + } + + fn render_composer(&self, cx: &mut Context) -> impl IntoElement { + let writable = self.notice(cx).is_none_or(|notice| notice.writable()); + + h_flex() + .flex_shrink_0() + .w_full() + .p_2() + .gap_1() + .items_end() + .child(Textarea::new(&self.input).appearance(false).flex_1()) + .child( + Button::new("send") + .icon(IconName::PaperPlaneFill) + .tooltip("Send") + .ghost() + .large() + .disabled(!writable) + .on_click(cx.listener(|this, _event, window, cx| { + this.send(window, cx); + })), + ) + } +} + +impl Panel for CommunityPanel { + fn panel_id(&self) -> SharedString { + self.id.clone() + } + + fn title(&self, cx: &App) -> AnyElement { + self.community + .read_with(cx, |community, _cx| { + let seed = community.id().to_hex(); + let avatar = match community.icon() { + Some(path) => Avatar::from_source(path).seed(seed).xsmall(), + None => Avatar::new(None).seed(seed).xsmall(), + }; + + h_flex() + .gap_1p5() + .child(avatar) + .child(SharedString::from(community.name())) + .into_any_element() + }) + .unwrap_or_else(|_| div().child("Unknown").into_any_element()) + } + + fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec