Author SHA1 Message Date
reya cd3f068c5e fix subscription 2026-09-19 10:55:09 +07:00
reya cbb97e471f update community 2026-09-19 10:33:11 +07:00
reya 97e7539cea update publish community 2026-09-19 09:27:41 +07:00
reya 9a391ca76d load community list 2026-09-19 08:56:41 +07:00
reya 8aad0685ad update concord store 2026-09-19 08:46:49 +07:00
reya 1cf5ecba51 refactor 2026-09-19 08:39:25 +07:00
reya 0328d35945 add community creation flow 2026-09-19 08:14:03 +07:00
reya 907347d002 update 2026-09-19 08:01:55 +07:00
reya aa3bd71351 update concord backend 2026-09-19 07:46:09 +07:00
reya d3b8fa08de use signer for community backend 2026-09-19 07:40:07 +07:00
reya 9081675494 refactor concord 2026-09-19 07:24:01 +07:00
reya 91c40b0799 refactor state init 2026-09-18 20:22:51 +07:00
reya 1b6ef6f574 clean up 2026-09-18 20:06:16 +07:00
reya f9e538257a update 2026-09-18 20:00:12 +07:00
reya 70140b2454 update community backend 2026-09-18 19:27:30 +07:00
reya 2def7548ef add community crate 2026-09-18 18:49:21 +07:00
reya 1320a2c361 refactor avatar 2026-09-18 17:03:17 +07:00
reya 88005fbc41 update sidebar 2026-09-18 15:53:40 +07:00
reya 41bd0ce345 . 2026-09-18 15:43:48 +07:00
reya 492e50746f wip 2026-09-18 15:35:42 +07:00
reya e75b1b9f10 wip 2026-09-18 15:17:42 +07:00
reya 0ad491cb92 refactor sidebar (wip) 2026-09-18 15:04:57 +07:00
reya 98903de1d0 add plan 2026-09-18 13:44:22 +07:00
reya 2ccbfcd4a8 redesign dock and titlebar 2026-09-18 13:21:40 +07:00
63 changed files with 6532 additions and 1835 deletions
Generated
+22
View File
@@ -1274,6 +1274,23 @@ dependencies = [
"regex", "regex",
] ]
[[package]]
name = "community"
version = "1.0.2"
dependencies = [
"anyhow",
"concord",
"flume 0.11.1",
"gpui-pre",
"log",
"nostr-memory",
"nostr-sdk",
"serde_json",
"smallvec",
"smol",
"state",
]
[[package]] [[package]]
name = "compression-codecs" name = "compression-codecs"
version = "0.4.43" version = "0.4.43"
@@ -1297,10 +1314,12 @@ name = "concord"
version = "1.0.2" version = "1.0.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.22.1",
"chacha20 0.9.1", "chacha20 0.9.1",
"data-encoding", "data-encoding",
"hkdf", "hkdf",
"hmac 0.12.1", "hmac 0.12.1",
"log",
"nostr", "nostr",
"nostr-memory", "nostr-memory",
"nostr-sdk", "nostr-sdk",
@@ -1412,6 +1431,7 @@ dependencies = [
"auto_update", "auto_update",
"chat", "chat",
"common", "common",
"community",
"device", "device",
"gpui-pre", "gpui-pre",
"gpui-pre-linux", "gpui-pre-linux",
@@ -1437,6 +1457,7 @@ dependencies = [
"assets", "assets",
"chat", "chat",
"common", "common",
"community",
"console_error_panic_hook", "console_error_panic_hook",
"console_log", "console_log",
"device", "device",
@@ -9323,6 +9344,7 @@ dependencies = [
"chat", "chat",
"chat_ui", "chat_ui",
"common", "common",
"community",
"device", "device",
"gpui-pre", "gpui-pre",
"instant", "instant",
+1
View File
@@ -36,6 +36,7 @@ nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "ni
# Crypto (NIP-17 encrypted file messages) # Crypto (NIP-17 encrypted file messages)
aes-gcm = "0.10" aes-gcm = "0.10"
base64 = "0.22"
sha2 = "0.10" sha2 = "0.10"
data-encoding = "2" data-encoding = "2"
hkdf = "0.12" hkdf = "0.12"
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="9.25" stroke="currentColor" stroke-width="1.5"/><ellipse cx="12" cy="12" rx="3.5" ry="9.25" stroke="currentColor" stroke-width="1.5"/><path d="M3.5 9.25H20.5" stroke="currentColor" stroke-width="1.5"/><path d="M3.5 14.75H20.5" stroke="currentColor" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 377 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M2.75 5.75V17.25C2.75 18.3546 3.64543 19.25 4.75 19.25H19.25C20.3546 19.25 21.25 18.3546 21.25 17.25V8.75C21.25 7.64543 20.3546 6.75 19.25 6.75H13.0704C12.4017 6.75 11.7772 6.4158 11.4063 5.8594L10.5937 4.6406C10.2228 4.0842 9.59834 3.75 8.92963 3.75H4.75C3.64543 3.75 2.75 4.64543 2.75 5.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 473 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M21.75 12C21.75 6.84375 17.9583 3.75 12 3.75C6.04167 3.75 2.25 6.84375 2.25 12C2.25 13.3368 3.17054 15.6055 3.3145 15.9522C3.32742 15.9833 3.34021 16.0117 3.3518 16.0433C3.45089 16.3136 3.85722 17.7527 2.25 19.8828C4.41667 20.914 6.71766 19.2188 6.71766 19.2188C8.30963 20.0597 10.2038 20.25 12 20.25C17.9583 20.25 21.75 17.1562 21.75 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="square" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 520 B

+8 -15
View File
@@ -3,7 +3,7 @@
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; 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 gpui_updater_core::{EngineConfig, Release, UpdateEngine, UpdateStatus, Verification, Version};
use instant::Duration; use instant::Duration;
@@ -35,7 +35,7 @@ fn uses_managed_updates() -> bool {
} }
/// Initialize the auto-update system. /// Initialize the auto-update system.
pub fn init(window: &mut Window, cx: &mut App) { pub fn init(cx: &mut App) {
if uses_managed_updates() { if uses_managed_updates() {
log::info!( log::info!(
"Skipping auto-update initialization: updates are managed by the installed distribution channel (Flatpak/Snap)" "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; return;
}; };
AutoUpdater::set_global( AutoUpdater::set_global(cx.new(|cx| AutoUpdater::new(version, filter, cx)), cx);
cx.new(|cx| AutoUpdater::new(window, version, filter, cx)),
cx,
);
} }
struct GlobalAutoUpdater(Entity<AutoUpdater>); struct GlobalAutoUpdater(Entity<AutoUpdater>);
@@ -103,21 +100,17 @@ impl AutoUpdater {
cx.set_global(GlobalAutoUpdater(state)); cx.set_global(GlobalAutoUpdater(state));
} }
fn new( fn new(version: Version, filter: AssetFilter, cx: &mut Context<Self>) -> Self {
window: &mut Window, let entity = cx.entity().downgrade();
version: Version,
filter: AssetFilter,
cx: &mut Context<Self>,
) -> Self {
let source = GiteaSource::new(GITEA_API_BASE, GITEA_REPO_OWNER, GITEA_REPO_NAME, filter); let source = GiteaSource::new(GITEA_API_BASE, GITEA_REPO_OWNER, GITEA_REPO_NAME, filter);
let config = EngineConfig::new(version.clone()).verification(Verification::Checksum); let config = EngineConfig::new(version.clone()).verification(Verification::Checksum);
let engine = Arc::new(UpdateEngine::new(source, config)); let engine = Arc::new(UpdateEngine::new(source, config));
// Schedule an auto-check after a 2-minute delay // Schedule an auto-check after a 2-minute delay
cx.defer_in(window, |_this, _window, cx| { cx.defer(move |cx| {
cx.spawn(async move |this, cx| { cx.spawn(async move |cx| {
cx.background_executor().timer(AUTO_CHECK_DELAY).await; 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(); .detach();
}); });
+25 -7
View File
@@ -26,8 +26,8 @@ pub use state::FileAttachment;
/// A static keypair used only for signing locally-cached rumor events. /// A static keypair used only for signing locally-cached rumor events.
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate); static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
pub fn init(window: &mut Window, cx: &mut App) { pub fn init(cx: &mut App) {
ChatRegistry::set_global(cx.new(|cx| ChatRegistry::new(window, cx)), cx); ChatRegistry::set_global(cx.new(ChatRegistry::new), cx);
} }
struct GlobalChatRegistry(Entity<ChatRegistry>); struct GlobalChatRegistry(Entity<ChatRegistry>);
@@ -150,7 +150,8 @@ impl ChatRegistry {
} }
/// Create a new chat registry instance /// Create a new chat registry instance
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { fn new(cx: &mut Context<Self>) -> Self {
let entity = cx.entity().downgrade();
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let (tx, rx) = flume::unbounded::<Signal>(); let (tx, rx) = flume::unbounded::<Signal>();
let mut subscriptions = smallvec![]; let mut subscriptions = smallvec![];
@@ -167,9 +168,12 @@ impl ChatRegistry {
}), }),
); );
// Run at the end of the current cycle cx.defer(move |cx| {
cx.defer_in(window, |this, _window, cx| { entity
this.get_rooms(cx); .update(cx, |this, cx| {
this.get_rooms(cx);
})
.ok();
}); });
Self { Self {
@@ -221,7 +225,21 @@ impl ChatRegistry {
}; };
match *message { 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 // Prune the dedup set before it grows unbounded
if processed_events.len() >= MAX_PROCESSED { if processed_events.len() >= MAX_PROCESSED {
processed_events.clear(); processed_events.clear();
+14 -5
View File
@@ -289,12 +289,21 @@ impl Room {
} }
} }
/// Gets the display image for the room /// Gets the display picture for the room, if it has one
pub fn display_image(&self, cx: &App) -> SharedString { pub fn display_image(&self, cx: &App) -> Option<SharedString> {
if !self.is_group() { if self.is_group() {
self.display_member(cx).avatar() None
} else { } 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()
} }
} }
+5 -3
View File
@@ -1203,6 +1203,7 @@ impl ChatPanel {
if show_author { if show_author {
this.child( this.child(
Avatar::new(author.avatar()) Avatar::new(author.avatar())
.seed(author.avatar_seed())
.flex_shrink_0() .flex_shrink_0()
.relative() .relative()
.dropdown_menu(move |this, _window, _cx| { .dropdown_menu(move |this, _window, _cx| {
@@ -1470,7 +1471,7 @@ impl ChatPanel {
h_flex() h_flex()
.gap_1() .gap_1()
.font_semibold() .font_semibold()
.child(Avatar::new(avatar).small()) .child(Avatar::new(avatar).seed(profile.avatar_seed()).small())
.child(name.clone()), .child(name.clone()),
), ),
) )
@@ -1978,11 +1979,12 @@ impl Panel for ChatPanel {
self.room self.room
.read_with(cx, |this, cx| { .read_with(cx, |this, cx| {
let label = this.display_name(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() h_flex()
.gap_1p5() .gap_1p5()
.child(Avatar::new(url).xsmall()) .child(Avatar::new(picture).seed(seed).xsmall())
.child(label) .child(label)
.into_any_element() .into_any_element()
}) })
+22
View File
@@ -0,0 +1,22 @@
[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
log.workspace = true
serde_json.workspace = true
smallvec.workspace = true
[dev-dependencies]
nostr-memory.workspace = true
smol.workspace = true
+186
View File
@@ -0,0 +1,186 @@
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use anyhow::Result;
use concord::cord02::{ControlFold, ImageRef};
use concord::store::{ChannelKeyRef, CommunityState};
use concord::{ChannelId, CommunityId, Epoch};
use gpui::{AppContext, Context, EventEmitter, Task};
use nostr_sdk::prelude::*;
use state::NostrRegistry;
use crate::sync::{self, Snapshot};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubscriptionKey {
control_pks: BTreeMap<u64, PublicKey>,
channels: Vec<(ChannelId, Epoch, bool)>,
relays: Vec<RelayUrl>,
}
impl SubscriptionKey {
fn of(state: &CommunityState) -> Self {
Self {
control_pks: state.control_pks.clone(),
channels: state
.channels
.iter()
.map(|channel| (channel.id, channel.epoch, channel.private))
.collect(),
relays: state.relays.clone(),
}
}
pub(crate) fn relays(&self) -> &[RelayUrl] {
&self.relays
}
}
#[derive(Debug, Clone)]
pub enum CommunityEvent {
Updated(CommunityId),
Error(String),
}
pub struct Community {
state: CommunityState,
control: ControlFold,
members: BTreeSet<PublicKey>,
icon: Option<PathBuf>,
icon_ref: Option<ImageRef>,
dirty: bool,
refresh_task: Option<Task<Result<()>>>,
icon_task: Option<Task<Result<()>>>,
}
impl EventEmitter<CommunityEvent> for Community {}
impl Community {
pub fn new(state: CommunityState) -> Self {
Self {
state,
control: ControlFold::default(),
members: BTreeSet::new(),
icon: None,
icon_ref: None,
dirty: false,
refresh_task: None,
icon_task: None,
}
}
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<PathBuf> {
self.icon.clone()
}
pub fn members(&self) -> &BTreeSet<PublicKey> {
&self.members
}
pub fn channels(&self) -> &[ChannelKeyRef] {
&self.state.channels
}
pub fn subscription_key(&self) -> SubscriptionKey {
SubscriptionKey::of(&self.state)
}
/// Rebuilds the community from the wraps in the local database.
pub fn refresh(&mut self, cx: &mut Context<Self>) {
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<Option<Snapshot>>, cx: &mut Context<Self>) {
self.refresh_task = None;
match result {
Ok(Some(snapshot)) => {
self.state = snapshot.state;
self.control = snapshot.control;
self.members = snapshot.members;
self.load_icon(cx);
cx.emit(CommunityEvent::Updated(self.state.id));
cx.notify();
}
Ok(None) => {}
Err(error) => cx.emit(CommunityEvent::Error(error.to_string())),
}
if self.dirty {
self.dirty = false;
self.refresh(cx);
}
}
/// Resolve the folded icon into a local file.
fn load_icon(&mut self, cx: &mut Context<Self>) {
let icon = self
.control
.community
.as_ref()
.and_then(|metadata| metadata.icon.clone());
if self.icon_ref == icon {
return;
}
self.icon_ref = icon.clone();
self.icon = None;
let Some(icon) = icon else {
return;
};
self.icon_task = Some(cx.spawn(async move |this, cx| {
match sync::resolve_icon(&icon, cx).await {
Ok(path) => {
this.update(cx, |this, cx| {
this.icon = Some(path);
cx.notify();
})?;
}
Err(error) => log::warn!("community icon: {error}"),
}
Ok(())
}));
}
}
+389
View File
@@ -0,0 +1,389 @@
use std::collections::HashMap;
use anyhow::Result;
use concord::CommunityId;
use concord::cord01::KIND_WRAP;
pub use concord::cord02::CommunityMetadata;
use concord::store::CommunityState;
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
use state::NostrRegistry;
mod community;
mod sync;
pub use community::*;
pub use sync::*;
pub fn init(cx: &mut App) {
CommunityRegistry::set_global(cx.new(CommunityRegistry::new), cx);
}
struct GlobalCommunityRegistry(Entity<CommunityRegistry>);
impl Global for GlobalCommunityRegistry {}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Signal {
Event(CommunityId),
List,
}
impl EventEmitter<CommunityEvent> for CommunityRegistry {}
pub struct CommunityRegistry {
communities: Vec<Entity<Community>>,
index: HashMap<CommunityId, Entity<Community>>,
/// The plane set each community was last subscribed with
synced: HashMap<CommunityId, SubscriptionKey>,
/// One observer per tracked community, dropped on reset
observers: Vec<Subscription>,
signal_tx: flume::Sender<Signal>,
signal_rx: flume::Receiver<Signal>,
tasks: SmallVec<[Task<Result<()>>; 2]>,
/// Notification listener task (cancelled on signer change)
notification_listener: Option<Task<Result<()>>>,
/// Signal consumer task (cancelled on signer change)
signal_consumer: Option<Task<Result<()>>>,
_subscriptions: SmallVec<[Subscription; 2]>,
}
impl CommunityRegistry {
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalCommunityRegistry>().0.clone()
}
fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalCommunityRegistry(state));
}
fn new(cx: &mut Context<Self>) -> Self {
let entity = cx.entity().downgrade();
let nostr = NostrRegistry::global(cx);
let (tx, rx) = flume::bounded::<Signal>(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(),
observers: Vec::new(),
signal_tx: tx,
signal_rx: rx,
tasks: smallvec![],
notification_listener: None,
signal_consumer: None,
_subscriptions: subscriptions,
}
}
pub fn communities(&self) -> &[Entity<Community>] {
&self.communities
}
pub fn community(&self, id: &CommunityId) -> Option<Entity<Community>> {
self.index.get(id).cloned()
}
/// Create a community owned by the current account and begin tracking it.
pub fn create(&mut self, metadata: CommunityMetadata, cx: &mut Context<Self>) {
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>) {
self.notification_listener = None;
self.signal_consumer = None;
self.tasks.clear();
self.observers.clear();
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let ids: Vec<CommunityId> = self.index.keys().copied().collect();
for id in ids {
let client = client.clone();
let subscription = sync::subscription_id(&id);
self.tasks.push(cx.background_spawn(async move {
client.unsubscribe(&subscription).await?;
Ok(())
}));
}
self.communities.clear();
self.index.clear();
self.synced.clear();
cx.notify();
}
/// Subscribe to the account's community list.
fn subscribe_list(&mut self, cx: &mut Context<Self>) {
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<Self>) {
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.
fn track(&mut self, states: Vec<CommunityState>, cx: &mut Context<Self>) {
self.observers.clear();
self.communities.clear();
self.index.clear();
self.synced.clear();
for state in states {
let id = state.id;
let community = cx.new(|_| Community::new(state));
self.observers
.push(cx.observe(&community, |this, _community, cx| {
this.sync_subscriptions(cx);
cx.notify();
}));
self.index.insert(id, community.clone());
self.communities.push(community);
}
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<Self>) {
let Some(community) = self.index.get(&id).cloned() else {
return;
};
community.update(cx, |community, cx| community.refresh(cx));
}
/// Re-subscribe every community whose held planes moved.
fn sync_subscriptions(&mut self, cx: &mut Context<Self>) {
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::subscription_filter(&planes);
let relays = key.relays().to_vec();
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()));
})?;
}
Ok(())
}));
}
}
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
self.notification_listener = None;
self.signal_consumer = None;
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let tx = self.signal_tx.clone();
let rx = self.signal_rx.clone();
self.notification_listener = Some(cx.background_spawn(async move {
let mut notifications = client.notifications();
while let Some(notification) = notifications.next().await {
let ClientNotification::Event {
subscription_id,
event,
..
} = notification
else {
continue;
};
if sync::is_list_subscription(&subscription_id) {
tx.send_async(Signal::List).await?;
continue;
}
if event.kind != Kind::from(KIND_WRAP) {
continue;
}
let Some(id) = sync::community_of(&subscription_id) else {
continue;
};
tx.send_async(Signal::Event(id)).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| this.refresh(id, cx))?,
Signal::List => this.update(cx, |this, cx| this.load(cx))?,
}
}
Ok(())
}));
}
}
async fn subscribe(
client: &Client,
id: &SubscriptionId,
relays: &[RelayUrl],
filter: Filter,
) -> Result<()> {
client.unsubscribe(id).await?;
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}");
}
}
let target = if relays.is_empty() {
ReqTarget::auto(vec![filter])
} else {
ReqTarget::manual(
relays
.iter()
.map(|url| (url.clone(), vec![filter.clone()]))
.collect::<Vec<_>>(),
)
};
let output = client.subscribe(target).with_id(id.clone()).await?;
if !output.failed.is_empty() {
log::warn!(
"community {id}: {} relay(s) rejected the subscription",
output.failed.len()
);
}
Ok(())
}
+911
View File
@@ -0,0 +1,911 @@
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use anyhow::{Context, Result};
use concord::cord01::KIND_WRAP;
use concord::cord02::list::{CommunityList, 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::store::{self, CommunityState};
use concord::{ChannelId, CommunityId, Epoch, GroupKey};
use gpui::AsyncApp;
use nostr_sdk::prelude::*;
use state::UniversalSigner;
#[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,
}
pub fn planes(state: &CommunityState) -> Result<Vec<Plane>> {
let mut planes = Vec::new();
for (epoch, address) in &state.control_pks {
let epoch = Epoch(*epoch);
let group = control_group_key(&state.community_root, &state.id, epoch)?;
planes.push(Plane {
kind: PlaneKind::Control(epoch),
address: *address,
group,
});
}
if state.control_pks.is_empty() {
let group = control_group_key(&state.community_root, &state.id, state.root_epoch)?;
planes.push(Plane {
kind: PlaneKind::Control(state.root_epoch),
address: group.pk(),
group,
});
}
let group = guestbook_group_key(&state.community_root, &state.id, state.root_epoch)?;
planes.push(Plane {
kind: PlaneKind::Guestbook,
address: group.pk(),
group,
});
for channel in &state.channels {
if channel.private {
continue;
}
let group = channel_group_key(&state.community_root, &channel.id, channel.epoch)?;
planes.push(Plane {
kind: PlaneKind::Channel(channel.id, channel.epoch),
address: group.pk(),
group,
});
}
Ok(planes)
}
pub fn subscription_filter(planes: &[Plane]) -> Filter {
Filter::new()
.kinds([Kind::from(KIND_WRAP)])
.authors(planes.iter().map(|plane| plane.address))
}
/// The subscription id carrying a community's planes.
pub fn subscription_id(id: &CommunityId) -> SubscriptionId {
SubscriptionId::new(id.to_hex())
}
pub fn community_of(subscription_id: &SubscriptionId) -> Option<CommunityId> {
subscription_id.as_str().parse().ok()
}
/// Download and decrypt a community icon into a content-addressed cache file.
pub async fn resolve_icon(icon: &ImageRef, cx: &AsyncApp) -> Result<PathBuf> {
let url = Url::parse(&icon.url).context("community icon url")?;
state::download_and_decrypt_to_cache(&url, &icon.key, &icon.nonce, &icon.hash, cx).await
}
#[derive(Debug, Clone)]
pub struct Snapshot {
pub state: CommunityState,
pub control: ControlFold,
pub members: BTreeSet<PublicKey>,
}
pub async fn create<S>(
client: &Client,
signer: &S,
metadata: &cord02::CommunityMetadata,
) -> Result<CommunityState>
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))?;
store::save_state(client, &state).await?;
publish_wraps(client, &genesis.wraps, &state.relays).await;
if let Err(error) = record_membership(client, signer, &state, &metadata.name).await {
log::warn!(
"community {}: recording the membership failed: {error}",
state.id.to_hex()
);
}
Ok(state)
}
/// Best-effort publication of the genesis wraps to the community's relays.
async fn publish_wraps(client: &Client, wraps: &[Event], relays: &[RelayUrl]) {
for url in relays {
if let Err(error) = client.add_relay(url).and_connect().await {
log::warn!("community genesis: failed to add relay {url}: {error}");
}
}
for wrap in wraps {
let sent = if relays.is_empty() {
client.send_event(wrap).broadcast().await
} else {
client.send_event(wrap).to(relays.iter().cloned()).await
};
match sent {
Ok(output) if output.failed.is_empty() => {}
Ok(output) => log::warn!(
"community genesis: {} relay(s) rejected {}",
output.failed.len(),
wrap.id
),
Err(error) => log::warn!("community genesis: publishing {} failed: {error}", wrap.id),
}
}
}
async fn record_membership<S>(
client: &Client,
signer: &S,
state: &CommunityState,
name: &str,
) -> Result<()>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let self_pk = signer.get_public_key_async().await?;
let held = load_list(client, signer, self_pk).await?;
let frags = held.as_ref().map_or(1, |list| list.frags);
if frags > 1 {
log::warn!(
"community {}: the list spans {frags} fragments; deferring the membership write",
state.id.to_hex()
);
return Ok(());
}
let entry = store::list_entry(state, name);
let list = match held {
Some(held) => held.joined(entry),
None => CommunityList::default().joined(entry),
};
let previous = newest_fragment_at(client, self_pk).await?;
let now = Timestamp::now().as_secs();
let at_secs = previous.map_or(now, |previous| now.max(previous.as_secs() + 1));
let event = cord02::list::build_list_event(signer, &list, 0, at_secs).await?;
publish_list(client, &event).await;
Ok(())
}
async fn publish_list(client: &Client, event: &Event) {
match client.send_event(event).to_nip65().await {
Ok(output) if output.failed.is_empty() => {}
Ok(output) => log::warn!(
"community list: {} relay(s) rejected the publish",
output.failed.len()
),
Err(error) => log::warn!("community list: publish failed: {error}"),
}
}
/// The newest `created_at` the account holds across its list fragments.
async fn newest_fragment_at(client: &Client, self_pk: PublicKey) -> Result<Option<Timestamp>> {
Ok(newest_fragments(client, self_pk)
.await?
.into_values()
.map(|event| event.created_at)
.max())
}
/// The subscription id carrying the account's own Community List.
pub const LIST_SUBSCRIPTION: &str = "concord/list";
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<Vec<CommunityState>> {
let list = match load_list(client, signer, self_pk).await? {
Some(list) => list,
None => return store::load_states(client).await,
};
let mut held: BTreeMap<CommunityId, CommunityState> = store::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 state = match held.remove(&entry.community_id) {
Some(materialized) => refresh(materialized, fresh),
None => fresh,
};
store::save_state(client, &state).await?;
held.insert(entry.community_id, state);
}
Ok(held.into_values().collect())
}
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 {
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 {
match held.channels.iter_mut().find(|held| held.id == channel.id) {
Some(held) => {
held.name = channel.name;
held.epoch = channel.epoch;
if channel.private {
held.private = true;
held.key = channel.key;
}
}
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<BTreeMap<u64, Event>> {
let filter = Filter::new()
.kind(Kind::Custom(KIND_COMMUNITY_LIST))
.author(self_pk);
let mut newest: BTreeMap<u64, Event> = 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<S>(
client: &Client,
signer: &S,
self_pk: PublicKey,
) -> Result<Option<CommunityList>>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
let mut merged: Option<CommunityList> = None;
for event in newest_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<Option<Snapshot>> {
let planes = planes(state)?;
if planes.is_empty() {
return Ok(None);
}
let wraps = client
.database()
.query(subscription_filter(&planes))
.await?;
let mut editions = Vec::new();
let mut observed: BTreeMap<PublicKey, u64> = BTreeMap::new();
let mut guestbook_rumors = Vec::new();
for wrap in &wraps {
let Some(plane) = planes.iter().find(|plane| plane.address == wrap.pubkey) else {
continue;
};
match plane.kind {
PlaneKind::Control(_) => {
if let Ok(edition) = cord02::open_edition(wrap, &plane.group, &plane.address, true)
{
editions.push(edition);
}
}
PlaneKind::Guestbook => {
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 Ok((opened, rumor)) =
concord::cord03::open(wrap, &plane.group, &channel, epoch)
{
store::cache_rumor(client, &channel, &opened).await?;
observe(&mut observed, rumor.author, rumor.at_ms);
}
}
}
}
if editions.is_empty() {
return Ok(None);
}
let control = cord02::fold_control(
&state.owner,
&state.id,
&editions,
&state.floors(),
&state.banned,
);
let granted: BTreeSet<PublicKey> = 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, None, 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);
store::save_state(client, &state).await?;
Ok(Some(Snapshot {
state,
control,
members,
}))
}
fn observe(observed: &mut BTreeMap<PublicKey, u64>, 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 nostr_memory::MemoryDatabase;
use super::*;
fn client() -> Client {
ClientBuilder::default()
.database(MemoryDatabase::unbounded())
.build()
}
#[test]
fn planes_address_the_control_guestbook_and_only_public_channels() {
let owner = Keys::generate().public_key();
let control_pk = Keys::generate().public_key();
let general = ChannelId::from_bytes([0x9c; 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::store::ChannelKeyRef {
id: general,
name: "general".to_owned(),
private: false,
epoch: Epoch(0),
key: None,
},
concord::store::ChannelKeyRef {
id: ChannelId::from_bytes([0x9d; 32]),
name: "staff".to_owned(),
private: true,
epoch: Epoch(0),
key: Some([0x04; 32]),
},
],
relays: vec![RelayUrl::parse("wss://relay.example").expect("a url")],
heads: Vec::new(),
banned: BTreeSet::new(),
dissolved: false,
added_at_ms: 0,
};
let planes = planes(&state).expect("planes");
assert_eq!(planes.len(), 3);
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))
);
let filter = subscription_filter(&planes);
let addresses: BTreeSet<PublicKey> = planes.iter().map(|plane| plane.address).collect();
assert_eq!(filter.authors, Some(addresses));
assert_eq!(filter.kinds, Some(BTreeSet::from([Kind::from(KIND_WRAP)])));
}
fn metadata(name: &str) -> 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::store::ChannelKeyRef {
id: ChannelId::from_bytes([0x9c; 32]),
name: "general".to_owned(),
private: false,
epoch: Epoch(0),
key: None,
}],
relays: Vec::new(),
heads: Vec::new(),
banned: BTreeSet::new(),
dissolved: false,
added_at_ms: 1_700_000_000_000,
}
}
/// Puts a List in the database as the account's own fragment, exactly as a
/// relay would have delivered it.
async fn store_fragment<S>(client: &Client, signer: &S, list: &CommunityList)
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
let event = cord02::list::build_list_event(signer, list, 0, 1_700_000_000)
.await
.expect("builds");
client.database().save_event(&event).await.expect("saves");
}
/// What `CommunityRegistry` needs from a created community: a state document
/// `load` finds, a control plane the subscription filter actually addresses,
/// and a fold that survives an inbound control edit.
#[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 subscription filter must address the genesis wraps, or the registry
// would listen to a plane nothing is ever published on.
let planes = planes(&created).expect("planes");
let wraps = client
.database()
.query(subscription_filter(&planes))
.await
.expect("queries");
assert_eq!(wraps.len(), created.heads.len());
assert!(wraps.iter().all(|wrap| wrap.kind == Kind::from(KIND_WRAP)));
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 other half of `create`: the membership must reach the account's
/// Community List, and a later create must union into it rather than replace
/// it (CORD-02 §8 read-modify-write).
#[test]
fn creating_a_community_records_the_membership_in_the_list() {
smol::block_on(async {
let client = client();
let keys = Keys::generate();
let signer = UniversalSigner::new(keys.clone());
let self_pk = keys.public_key();
let created = create(&client, &signer, &metadata("coop"))
.await
.expect("creates");
let list = load_list(&client, &signer, self_pk)
.await
.expect("reads")
.expect("a list");
assert_eq!(list.frags, 1);
assert!(list.is_complete([0]), "the whole list is one fragment");
assert!(list.is_live(&created.id));
let entry = list
.entries
.iter()
.find(|entry| entry.community_id == created.id)
.expect("the membership");
assert_eq!(
entry.seed, entry.current,
"a fresh membership has one anchor"
);
assert_eq!(entry.current.name, "coop");
assert_eq!(entry.current.owner, created.owner);
assert_eq!(entry.current.root_epoch, created.root_epoch);
assert_eq!(
entry.current.control_pk,
created.control_pks.get(&0).copied()
);
assert!(
entry.current.control_root.is_some(),
"the owner holds the control root"
);
assert_eq!(entry.current.channels.len(), created.channels.len());
assert_eq!(entry.added_at, created.added_at_ms);
// A second create unions into the same document: the first
// membership survives and both are live.
let second = create(&client, &signer, &metadata("second"))
.await
.expect("creates");
let grown = load_list(&client, &signer, self_pk)
.await
.expect("reads")
.expect("a list");
assert!(grown.is_live(&created.id));
assert!(grown.is_live(&second.id));
assert_eq!(grown.entries.len(), 2);
});
}
/// The discovery fix: a membership the List carries is materialized even when
/// no state document has ever been written for it.
#[test]
fn load_materializes_a_membership_the_list_carries_with_no_state_document() {
smol::block_on(async {
let client = client();
let keys = Keys::generate();
let signer = UniversalSigner::new(keys.clone());
let listed = held(
CommunityId::from_bytes([0x42; 32]),
Keys::generate().public_key(),
);
let list = CommunityList::default().joined(store::list_entry(&listed, "coop"));
store_fragment(&client, &signer, &list).await;
assert!(
store::load_state(&client, &listed.id)
.await
.expect("reads")
.is_none()
);
let loaded = load(&client, &signer, keys.public_key())
.await
.expect("loads");
let materialized = loaded
.iter()
.find(|state| state.id == listed.id)
.expect("the list materializes the community");
assert_eq!(materialized.owner, listed.owner);
assert_eq!(materialized.community_root, listed.community_root);
assert_eq!(materialized.control_root, listed.control_root);
assert_eq!(materialized.control_pks, listed.control_pks);
assert_eq!(materialized.channels, listed.channels);
assert_eq!(materialized.added_at_ms, listed.added_at_ms);
// Discovery writes the document, so the next load is warm.
assert_eq!(
store::load_state(&client, &listed.id)
.await
.expect("reads")
.map(|state| state.id),
Some(listed.id)
);
});
}
/// Absence from the List is never a fact (§8): a held membership the List
/// does not mention survives alongside the one it does.
#[test]
fn load_keeps_a_local_membership_the_list_does_not_mention() {
smol::block_on(async {
let client = client();
let keys = Keys::generate();
let signer = UniversalSigner::new(keys.clone());
let listed = held(
CommunityId::from_bytes([0x42; 32]),
Keys::generate().public_key(),
);
let local = held(
CommunityId::from_bytes([0x43; 32]),
Keys::generate().public_key(),
);
let list = CommunityList::default().joined(store::list_entry(&listed, "listed"));
store_fragment(&client, &signer, &list).await;
store::save_state(&client, &local).await.expect("saves");
let loaded = load(&client, &signer, keys.public_key())
.await
.expect("loads");
let ids: BTreeSet<CommunityId> = loaded.iter().map(|state| state.id).collect();
assert!(ids.contains(&listed.id));
assert!(ids.contains(&local.id));
});
}
/// Only a tombstone subtracts a membership (§8).
#[test]
fn a_tombstone_drops_a_held_membership() {
smol::block_on(async {
let client = client();
let keys = Keys::generate();
let signer = UniversalSigner::new(keys.clone());
let local = held(
CommunityId::from_bytes([0x42; 32]),
Keys::generate().public_key(),
);
store::save_state(&client, &local).await.expect("saves");
let list = CommunityList::default().tombstoned(local.id, u64::MAX);
store_fragment(&client, &signer, &list).await;
let loaded = load(&client, &signer, keys.public_key())
.await
.expect("loads");
assert!(loaded.iter().all(|state| state.id != local.id));
});
}
/// The `concord/list` id must not be read as a community's subscription, or
/// every list event would refresh a community instead of triggering `load`.
#[test]
fn the_list_subscription_is_not_read_as_a_community_subscription() {
let id = CommunityId::from_bytes([0x42; 32]);
assert!(is_list_subscription(&list_subscription_id()));
assert!(community_of(&list_subscription_id()).is_none());
assert_eq!(community_of(&subscription_id(&id)), Some(id));
}
/// A relay answers a longer REQ with `invalid subscription id length` but
/// `subscribe` still reports success, so an over-long id fails silently.
#[test]
fn a_community_subscription_id_fits_the_nip01_cap() {
let id = CommunityId::from_bytes([0x42; 32]);
assert!(subscription_id(&id).as_str().len() <= 64);
}
}
+2
View File
@@ -13,10 +13,12 @@ sha2.workspace = true
chacha20.workspace = true chacha20.workspace = true
hmac.workspace = true hmac.workspace = true
data-encoding.workspace = true data-encoding.workspace = true
base64.workspace = true
rand.workspace = true rand.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
anyhow.workspace = true anyhow.workspace = true
log.workspace = true
[dev-dependencies] [dev-dependencies]
nostr-memory.workspace = true nostr-memory.workspace = true
+39 -19
View File
@@ -3,8 +3,8 @@ use std::fmt;
use data_encoding::BASE64; use data_encoding::BASE64;
use nostr::nips::nip44::v2::{ConversationKey, decrypt_to_bytes, encrypt_to_bytes_with_nonce}; use nostr::nips::nip44::v2::{ConversationKey, decrypt_to_bytes, encrypt_to_bytes_with_nonce};
use nostr_sdk::prelude::{ use nostr_sdk::prelude::{
Event, EventBuilder, EventId, FinalizeEvent, Keys, Kind, PublicKey, Tag, Timestamp, AsyncGetPublicKey, AsyncNip44, AsyncSignEvent, Event, EventBuilder, EventId, FinalizeEvent,
UnsignedEvent, FinalizeEventAsync, Keys, Kind, PublicKey, Tag, Timestamp, UnsignedEvent,
}; };
use crate::derive::GroupKey; use crate::derive::GroupKey;
@@ -198,32 +198,52 @@ pub fn open_bytes(conversation: &ConversationKey, content: &str) -> Result<Vec<u
} }
/// A member's own document (the Community List, the Invite List): NIP-44 to self. /// A member's own document (the Community List, the Invite List): NIP-44 to self.
pub fn seal_to_self(keys: &Keys, plaintext: &[u8]) -> Result<String, StreamError> { pub async fn seal_to_self<S>(signer: &S, plaintext: &str) -> Result<String, StreamError>
seal_bytes( where
&ConversationKey::derive(keys.secret_key(), &keys.public_key()) S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
.map_err(|error| StreamError::Encrypt(error.to_string()))?, {
plaintext, check_plaintext_cap(plaintext.len())?;
)
let address = signer
.get_public_key_async()
.await
.map_err(|error| StreamError::Encrypt(error.to_string()))?;
signer
.nip44_encrypt_async(&address, plaintext)
.await
.map_err(|error| StreamError::Encrypt(error.to_string()))
} }
pub fn open_to_self(keys: &Keys, content: &str) -> Result<Vec<u8>, StreamError> { pub async fn open_to_self<S>(signer: &S, content: &str) -> Result<String, StreamError>
open_bytes( where
&ConversationKey::derive(keys.secret_key(), &keys.public_key()) S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
.map_err(|error| StreamError::Decrypt(error.to_string()))?, {
content, let address = signer
) .get_public_key_async()
.await
.map_err(|error| StreamError::Decrypt(error.to_string()))?;
signer
.nip44_decrypt_async(&address, content)
.await
.map_err(|error| StreamError::Decrypt(error.to_string()))
} }
pub fn build_seal( pub async fn build_seal<S>(
rumor: &UnsignedEvent, rumor: &UnsignedEvent,
form: SealForm, form: SealForm,
group: &GroupKey, group: &GroupKey,
author: &Keys, author: &S,
) -> Result<Event, StreamError> { ) -> Result<Event, StreamError>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let content = seal_content(rumor, form, group)?; let content = seal_content(rumor, form, group)?;
EventBuilder::new(Kind::Custom(form.kind()), content) EventBuilder::new(Kind::Custom(form.kind()), content)
.custom_created_at(rumor.created_at) .custom_created_at(rumor.created_at)
.finalize(author) .finalize_async(author)
.await
.map_err(|error| StreamError::Sign(error.to_string())) .map_err(|error| StreamError::Sign(error.to_string()))
} }
@@ -450,7 +470,7 @@ mod tests {
} }
fn sealed(rumor: &UnsignedEvent, form: SealForm, author: &Keys) -> Event { fn sealed(rumor: &UnsignedEvent, form: SealForm, author: &Keys) -> Event {
build_seal(rumor, form, &group(0), author).expect("seals") smol::block_on(build_seal(rumor, form, &group(0), author)).expect("seals")
} }
fn wrapped(seal: &Event, kind: u16, at_secs: u64) -> Event { fn wrapped(seal: &Event, kind: u16, at_secs: u64) -> Event {
+26 -104
View File
@@ -1,18 +1,16 @@
use std::cmp::Reverse; use std::cmp::Reverse;
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use anyhow::Result; use anyhow::Result;
use data_encoding::HEXLOWER; use data_encoding::HEXLOWER;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use crate::cord01::{ use crate::cord01::{
KIND_WRAP, OpenedStream, SealForm, StreamError, build_rumor_ms, build_seal, open_wrap, KIND_WRAP, OpenedStream, SealForm, build_rumor_ms, build_seal, open_wrap, wrap_seal,
wrap_seal,
};
use crate::cord04::{
AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag,
}; };
use crate::cord04::{AuthorityCitation, canonical_decimal, citation_tag};
pub use crate::cords::rumor::RumorError as GuestbookError;
use crate::cords::rumor::{optional_citation, pubkey, required, value};
use crate::{GroupKey, decode_hex_32}; use crate::{GroupKey, decode_hex_32};
pub const KIND_JOIN_LEAVE: u16 = 3306; pub const KIND_JOIN_LEAVE: u16 = 3306;
@@ -29,41 +27,6 @@ const TAG_CONTENT: &str = "content";
const CONTENT_JOIN: &str = "join"; const CONTENT_JOIN: &str = "join";
const CONTENT_LEAVE: &str = "leave"; const CONTENT_LEAVE: &str = "leave";
#[derive(Debug)]
pub enum GuestbookError {
Stream(StreamError),
NotEncryptedSealed,
UnknownKind(u16),
MissingTag(&'static str),
DuplicateTag(&'static str),
BadTag(&'static str),
}
impl fmt::Display for GuestbookError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
GuestbookError::Stream(error) => write!(f, "stream: {error}"),
GuestbookError::NotEncryptedSealed => {
write!(f, "guestbook rumor must ride an encrypted seal")
}
GuestbookError::UnknownKind(kind) => {
write!(f, "not a guestbook rumor kind: {kind}")
}
GuestbookError::MissingTag(name) => write!(f, "missing guestbook tag: {name}"),
GuestbookError::DuplicateTag(name) => write!(f, "duplicate guestbook tag: {name}"),
GuestbookError::BadTag(name) => write!(f, "malformed guestbook tag: {name}"),
}
}
}
impl std::error::Error for GuestbookError {}
impl From<StreamError> for GuestbookError {
fn from(error: StreamError) -> Self {
GuestbookError::Stream(error)
}
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum GuestbookEntry { pub enum GuestbookEntry {
Join { Join {
@@ -183,18 +146,21 @@ pub fn build_snapshot_chunks(
.collect() .collect()
} }
pub fn seal_rumor( pub async fn seal_rumor<S>(
rumor: &UnsignedEvent, rumor: &UnsignedEvent,
group: &GroupKey, group: &GroupKey,
author: &Keys, author: &S,
) -> Result<(Event, Keys), GuestbookError> { ) -> Result<(Event, Keys), GuestbookError>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let kind = rumor.kind.as_u16(); let kind = rumor.kind.as_u16();
if !is_guestbook_kind(kind) { if !is_guestbook_kind(kind) {
return Err(GuestbookError::UnknownKind(kind)); return Err(GuestbookError::UnknownKind(kind));
} }
let seal = build_seal(rumor, SealForm::Encrypted, group, author)?; let seal = build_seal(rumor, SealForm::Encrypted, group, author).await?;
Ok(wrap_seal(&seal, group, KIND_WRAP, rumor.created_at, &[])?) Ok(wrap_seal(&seal, group, KIND_WRAP, rumor.created_at, &[])?)
} }
@@ -466,73 +432,21 @@ fn snapshot_of(rumor: &UnsignedEvent) -> Result<([u8; 32], (u32, u32)), Guestboo
Ok((snapshot_id, (index, total))) Ok((snapshot_id, (index, total)))
} }
fn optional_citation(rumor: &UnsignedEvent) -> Result<Option<AuthorityCitation>, GuestbookError> {
let Some(fields) = tag(rumor, TAG_CITATION)? else {
return Ok(None);
};
citation_from(fields)
.map(Some)
.ok_or(GuestbookError::BadTag(TAG_CITATION))
}
fn decimal(raw: &str) -> Result<u32, GuestbookError> { fn decimal(raw: &str) -> Result<u32, GuestbookError> {
canonical_decimal(raw) canonical_decimal(raw)
.and_then(|value| u32::try_from(value).ok()) .and_then(|value| u32::try_from(value).ok())
.ok_or(GuestbookError::BadTag(TAG_SNAP)) .ok_or(GuestbookError::BadTag(TAG_SNAP))
} }
fn required<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<&'a [String], GuestbookError> {
tag(rumor, name)?.ok_or(GuestbookError::MissingTag(name))
}
fn tagged_pubkey(rumor: &UnsignedEvent, name: &'static str) -> Result<PublicKey, GuestbookError> { fn tagged_pubkey(rumor: &UnsignedEvent, name: &'static str) -> Result<PublicKey, GuestbookError> {
pubkey(value(required(rumor, name)?, name)?, name) pubkey(value(required(rumor, name)?, name)?, name)
} }
fn tag<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<Option<&'a [String]>, GuestbookError> {
let mut found: Option<&[String]> = None;
for candidate in rumor.tags.iter() {
let fields = candidate.as_slice();
if fields.first().map(String::as_str) != Some(name) {
continue;
}
if found.is_some() {
return Err(GuestbookError::DuplicateTag(name));
}
found = Some(fields);
}
Ok(found)
}
fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, GuestbookError> {
fields
.get(1)
.map(String::as_str)
.ok_or(GuestbookError::BadTag(name))
}
fn pubkey(hex: &str, name: &'static str) -> Result<PublicKey, GuestbookError> {
let bytes = decode_hex_32(hex).map_err(|_| GuestbookError::BadTag(name))?;
PublicKey::from_slice(&bytes).map_err(|_| GuestbookError::BadTag(name))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::cord01::build_rumor_secs; use crate::cord01::{StreamError, build_rumor_secs};
use crate::cord04::TAG_CITATION;
use crate::derive::guestbook_group_key; use crate::derive::guestbook_group_key;
use crate::{CommunityId, Epoch}; use crate::{CommunityId, Epoch};
@@ -556,7 +470,9 @@ mod tests {
} }
fn publish(rumor: &UnsignedEvent, author: &Keys) -> GuestbookRumor { fn publish(rumor: &UnsignedEvent, author: &Keys) -> GuestbookRumor {
let wrap = seal_rumor(rumor, &group(), author).expect("seals").0; let wrap = smol::block_on(seal_rumor(rumor, &group(), author))
.expect("seals")
.0;
open(&wrap, &group()).expect("opens").1 open(&wrap, &group()).expect("opens").1
} }
@@ -826,7 +742,9 @@ mod tests {
); );
assert!(matches!( assert!(matches!(
open( open(
&seal_rumor(&bad_ms, &group(), &member).expect("seals").0, &smol::block_on(seal_rumor(&bad_ms, &group(), &member))
.expect("seals")
.0,
&group() &group()
), ),
Err(GuestbookError::Stream(StreamError::BadMs)) Err(GuestbookError::Stream(StreamError::BadMs))
@@ -835,7 +753,9 @@ mod tests {
let bad_verb = build_rumor_ms(KIND_JOIN_LEAVE, member.public_key(), "maybe", vec![], AT); let bad_verb = build_rumor_ms(KIND_JOIN_LEAVE, member.public_key(), "maybe", vec![], AT);
assert!(matches!( assert!(matches!(
open( open(
&seal_rumor(&bad_verb, &group(), &member).expect("seals").0, &smol::block_on(seal_rumor(&bad_verb, &group(), &member))
.expect("seals")
.0,
&group() &group()
), ),
Err(GuestbookError::BadTag(TAG_CONTENT)) Err(GuestbookError::BadTag(TAG_CONTENT))
@@ -854,7 +774,7 @@ mod tests {
); );
assert!(matches!( assert!(matches!(
open( open(
&seal_rumor(&ambiguous, &group(), &moderator) &smol::block_on(seal_rumor(&ambiguous, &group(), &moderator))
.expect("seals") .expect("seals")
.0, .0,
&group() &group()
@@ -876,7 +796,9 @@ mod tests {
); );
assert!(matches!( assert!(matches!(
open( open(
&seal_rumor(&rumor, &group(), &moderator).expect("seals").0, &smol::block_on(seal_rumor(&rumor, &group(), &moderator))
.expect("seals")
.0,
&group() &group()
), ),
Err(GuestbookError::BadTag(TAG_SNAP)) Err(GuestbookError::BadTag(TAG_SNAP))
+637 -64
View File
@@ -1,15 +1,17 @@
use std::collections::BTreeMap;
use std::collections::btree_map::Entry; use std::collections::btree_map::Entry;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt; use std::fmt;
use data_encoding::HEXLOWER;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::cord01::{self, NIP44_MAX_PLAINTEXT}; use crate::cord01::{self, NIP44_MAX_PLAINTEXT};
use crate::cord05::{ChannelGrant, CommunityInvite}; use crate::cord05::{ChannelGrant, CommunityInvite};
use crate::{CommunityId, Epoch, Extra}; use crate::utils::{base64_to_hex32, base64url, canonical, hex32_to_base64, union};
use crate::{ChannelId, CommunityId, Epoch, Extra};
pub const KIND_COMMUNITY_LIST: u16 = 13302; pub const KIND_COMMUNITY_LIST: u16 = 33302;
pub const MAX_MEMBERSHIPS: usize = 50; pub const MAX_MEMBERSHIPS: usize = 50;
#[derive(Debug)] #[derive(Debug)]
@@ -17,6 +19,8 @@ pub enum ListError {
Kind(u16), Kind(u16),
Crypto(String), Crypto(String),
Json(String), Json(String),
Encoding(String),
Fragment(String),
TooManyMemberships(usize), TooManyMemberships(usize),
Oversize(usize), Oversize(usize),
} }
@@ -27,6 +31,8 @@ impl fmt::Display for ListError {
ListError::Kind(kind) => write!(f, "not a community list kind: {kind}"), ListError::Kind(kind) => write!(f, "not a community list kind: {kind}"),
ListError::Crypto(error) => write!(f, "crypto: {error}"), ListError::Crypto(error) => write!(f, "crypto: {error}"),
ListError::Json(error) => write!(f, "json: {error}"), ListError::Json(error) => write!(f, "json: {error}"),
ListError::Encoding(error) => write!(f, "encoding: {error}"),
ListError::Fragment(error) => write!(f, "fragment: {error}"),
ListError::TooManyMemberships(count) => { ListError::TooManyMemberships(count) => {
write!( write!(
f, f,
@@ -48,55 +54,58 @@ impl From<cord01::StreamError> for ListError {
} }
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq)]
pub struct JoinMaterial { pub struct JoinMaterial {
pub community_id: CommunityId, pub community_id: CommunityId,
pub owner: PublicKey, pub owner: PublicKey,
pub owner_salt: String, pub owner_salt: String,
pub community_root: String, pub community_root: String,
pub root_epoch: Epoch, pub root_epoch: Epoch,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub control_pk: Option<PublicKey>, pub control_pk: Option<PublicKey>,
/// Present only when the holder is staff. /// Present only when the holder is staff.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub control_root: Option<String>, pub control_root: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub channels: Vec<ChannelGrant>, pub channels: Vec<ChannelGrant>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub relays: Vec<String>, pub relays: Vec<String>,
pub name: String, pub name: String,
#[serde(flatten)]
pub extra: Extra, pub extra: Extra,
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq)]
pub struct CommunityListEntry { pub struct CommunityListEntry {
pub community_id: CommunityId, pub community_id: CommunityId,
pub seed: JoinMaterial, pub seed: JoinMaterial,
pub current: JoinMaterial, pub current: JoinMaterial,
pub added_at: u64, pub added_at: u64,
#[serde(flatten)]
pub extra: Extra, pub extra: Extra,
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq)]
pub struct Tombstone { pub struct Tombstone {
pub community_id: CommunityId, pub community_id: CommunityId,
pub removed_at: u64, pub removed_at: u64,
#[serde(flatten)]
pub extra: Extra, pub extra: Extra,
} }
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq)]
pub struct CommunityList { pub struct CommunityList {
#[serde(default, skip_serializing_if = "Vec::is_empty")] /// How many fragments this List has. Every fragment declares it.
pub frags: u64,
pub entries: Vec<CommunityListEntry>, pub entries: Vec<CommunityListEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tombstones: Vec<Tombstone>, pub tombstones: Vec<Tombstone>,
#[serde(flatten)]
pub extra: Extra, pub extra: Extra,
} }
impl Default for CommunityList {
fn default() -> Self {
Self {
frags: 1,
entries: Vec::new(),
tombstones: Vec::new(),
extra: Extra::default(),
}
}
}
impl CommunityList { impl CommunityList {
pub fn is_live(&self, community_id: &CommunityId) -> bool { pub fn is_live(&self, community_id: &CommunityId) -> bool {
let added = self let added = self
@@ -115,6 +124,38 @@ impl CommunityList {
} }
} }
pub fn is_complete<I>(&self, held: I) -> bool
where
I: IntoIterator<Item = u64>,
{
let held: BTreeSet<u64> = held.into_iter().collect();
(0..self.frags).all(|index| held.contains(&index))
}
pub fn joined(&self, entry: CommunityListEntry) -> CommunityList {
merge(
self.clone(),
CommunityList {
entries: vec![entry],
..Default::default()
},
)
}
pub fn tombstoned(&self, community_id: CommunityId, removed_at: u64) -> CommunityList {
merge(
self.clone(),
CommunityList {
tombstones: vec![Tombstone {
community_id,
removed_at,
extra: Extra::default(),
}],
..Default::default()
},
)
}
pub fn fits(&self) -> Result<(), ListError> { pub fn fits(&self) -> Result<(), ListError> {
if self.entries.len() > MAX_MEMBERSHIPS { if self.entries.len() > MAX_MEMBERSHIPS {
return Err(ListError::TooManyMemberships(self.entries.len())); return Err(ListError::TooManyMemberships(self.entries.len()));
@@ -138,7 +179,7 @@ pub fn join_material(invite: &CommunityInvite, control_root: Option<&[u8; 32]>)
community_root: invite.community_root.clone(), community_root: invite.community_root.clone(),
root_epoch: invite.root_epoch, root_epoch: invite.root_epoch,
control_pk: invite.control_pk, control_pk: invite.control_pk,
control_root: control_root.map(|key| data_encoding::HEXLOWER.encode(key)), control_root: control_root.map(|key| HEXLOWER.encode(key)),
channels: invite.channels.clone(), channels: invite.channels.clone(),
relays: invite.relays.clone(), relays: invite.relays.clone(),
name: invite.name.clone(), name: invite.name.clone(),
@@ -149,7 +190,9 @@ pub fn join_material(invite: &CommunityInvite, control_root: Option<&[u8; 32]>)
pub fn merge(held: CommunityList, incoming: CommunityList) -> CommunityList { pub fn merge(held: CommunityList, incoming: CommunityList) -> CommunityList {
let mut entries: BTreeMap<CommunityId, CommunityListEntry> = BTreeMap::new(); let mut entries: BTreeMap<CommunityId, CommunityListEntry> = BTreeMap::new();
for entry in held.entries.into_iter().chain(incoming.entries) { for mut entry in held.entries.into_iter().chain(incoming.entries) {
normalize(&mut entry);
match entries.entry(entry.community_id) { match entries.entry(entry.community_id) {
Entry::Vacant(slot) => { Entry::Vacant(slot) => {
slot.insert(entry); slot.insert(entry);
@@ -177,31 +220,361 @@ pub fn merge(held: CommunityList, incoming: CommunityList) -> CommunityList {
union(&mut extra, incoming.extra); union(&mut extra, incoming.extra);
CommunityList { CommunityList {
frags: held.frags.max(incoming.frags),
entries: entries.into_values().collect(), entries: entries.into_values().collect(),
tombstones: tombstones.into_values().collect(), tombstones: tombstones.into_values().collect(),
extra, extra,
} }
} }
pub fn build_list_event(keys: &Keys, list: &CommunityList) -> Result<Event, ListError> { pub async fn build_list_event<S>(
signer: &S,
list: &CommunityList,
fragment: u64,
at_secs: u64,
) -> Result<Event, ListError>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
list.fits()?; list.fits()?;
let json = serde_json::to_string(list).map_err(json_error)?; let json = serde_json::to_string(list).map_err(json_error)?;
let content = cord01::seal_to_self(keys, json.as_bytes())?; let content = cord01::seal_to_self(signer, &json).await?;
EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), content) EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), content)
.finalize(keys) .tag(Tag::identifier(fragment.to_string()))
.custom_created_at(Timestamp::from_secs(at_secs))
.finalize_async(signer)
.await
.map_err(crypto_error) .map_err(crypto_error)
} }
pub fn parse_list_event(keys: &Keys, event: &Event) -> Result<CommunityList, ListError> { pub async fn parse_list_event<S>(signer: &S, event: &Event) -> Result<CommunityList, ListError>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
if event.kind.as_u16() != KIND_COMMUNITY_LIST { if event.kind.as_u16() != KIND_COMMUNITY_LIST {
return Err(ListError::Kind(event.kind.as_u16())); return Err(ListError::Kind(event.kind.as_u16()));
} }
let json = cord01::open_to_self(keys, &event.content)?; fragment_index(event)?;
serde_json::from_slice(&json).map_err(json_error) let json = cord01::open_to_self(signer, &event.content).await?;
serde_json::from_str(&json).map_err(json_error)
}
pub fn fragment_index(event: &Event) -> Result<u64, ListError> {
let value = event
.tags
.identifier()
.ok_or_else(|| ListError::Fragment("missing d tag".to_owned()))?;
value
.parse()
.map_err(|_| ListError::Fragment(format!("d tag is not a fragment index: {value}")))
}
fn decode_base64(value: &str, field: &str) -> Result<[u8; 32], ListError> {
base64url::decode_32(value).map_err(|error| ListError::Encoding(format!("{field}: {error}")))
}
fn decode_community_id(value: &str) -> Result<CommunityId, ListError> {
Ok(CommunityId::from_bytes(decode_base64(
value,
"community_id",
)?))
}
fn decode_public_key(value: &str, field: &str) -> Result<PublicKey, ListError> {
PublicKey::from_slice(&decode_base64(value, field)?)
.map_err(|error| ListError::Encoding(format!("{field}: {error}")))
}
fn encode_hex_32(value: &str, field: &str) -> Result<String, ListError> {
hex32_to_base64(value).map_err(|error| ListError::Encoding(format!("{field}: {error}")))
}
fn decode_hex_32_base64(value: &str, field: &str) -> Result<String, ListError> {
base64_to_hex32(value).map_err(|error| ListError::Encoding(format!("{field}: {error}")))
}
#[derive(Debug, Serialize, Deserialize)]
struct WireList {
#[serde(default = "one_fragment")]
frags: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
entries: Vec<WireEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
tombstones: Vec<WireTombstone>,
#[serde(flatten)]
extra: Extra,
}
fn one_fragment() -> u64 {
1
}
#[derive(Debug, Serialize, Deserialize)]
struct WireEntry {
community_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
seed: Option<WireSnapshot>,
current: WireSnapshot,
added_at: u64,
#[serde(flatten)]
extra: Extra,
}
#[derive(Debug, Serialize, Deserialize)]
struct WireTombstone {
community_id: String,
removed_at: u64,
#[serde(flatten)]
extra: Extra,
}
#[derive(Debug, Serialize, Deserialize)]
struct WireSnapshot {
owner: String,
owner_salt: String,
community_root: String,
root_epoch: Epoch,
#[serde(default, skip_serializing_if = "Option::is_none")]
control_pk: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
control_root: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
channels: Vec<WireChannel>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
relays: Vec<String>,
name: String,
#[serde(flatten)]
extra: Extra,
}
#[derive(Debug, Serialize, Deserialize)]
struct WireChannel {
id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
key: Option<String>,
epoch: Epoch,
#[serde(default)]
name: String,
#[serde(flatten)]
extra: Extra,
}
impl WireList {
fn encode(list: &CommunityList) -> Result<Self, ListError> {
// §8: a retired entry is not written — the tombstone alone carries the
// state, since a membership is live only while an entry outranks its
// removal. The entry stays in the in-memory document.
let mut entries = Vec::with_capacity(list.entries.len());
for entry in &list.entries {
if list.is_live(&entry.community_id) {
entries.push(WireEntry::encode(entry)?);
}
}
let mut tombstones = Vec::with_capacity(list.tombstones.len());
for tombstone in &list.tombstones {
tombstones.push(WireTombstone::encode(tombstone)?);
}
Ok(Self {
frags: list.frags,
entries,
tombstones,
extra: list.extra.clone(),
})
}
fn decode(self) -> Result<CommunityList, ListError> {
let mut entries = Vec::with_capacity(self.entries.len());
for entry in self.entries {
entries.push(entry.decode()?);
}
let mut tombstones = Vec::with_capacity(self.tombstones.len());
for tombstone in self.tombstones {
tombstones.push(tombstone.decode()?);
}
Ok(CommunityList {
frags: self.frags.max(1),
entries,
tombstones,
extra: self.extra,
})
}
}
impl WireEntry {
fn encode(entry: &CommunityListEntry) -> Result<Self, ListError> {
let current = WireSnapshot::encode(&entry.current)?;
let mut seed = entry.seed.clone();
normalize_snapshot(&mut seed, &entry.current);
let seed = if seed == entry.current {
None
} else {
Some(WireSnapshot::encode(&seed)?)
};
Ok(Self {
community_id: base64url::encode(entry.community_id.as_bytes()),
seed,
current,
added_at: entry.added_at,
extra: entry.extra.clone(),
})
}
fn decode(self) -> Result<CommunityListEntry, ListError> {
let community_id = decode_community_id(&self.community_id)?;
let current = self.current.decode(community_id)?;
let seed = match self.seed {
Some(seed) => seed.decode(community_id)?,
None => current.clone(),
};
let mut entry = CommunityListEntry {
community_id,
seed,
current,
added_at: self.added_at,
extra: self.extra,
};
normalize(&mut entry);
Ok(entry)
}
}
impl WireTombstone {
fn encode(tombstone: &Tombstone) -> Result<Self, ListError> {
Ok(Self {
community_id: base64url::encode(tombstone.community_id.as_bytes()),
removed_at: tombstone.removed_at,
extra: tombstone.extra.clone(),
})
}
fn decode(self) -> Result<Tombstone, ListError> {
Ok(Tombstone {
community_id: decode_community_id(&self.community_id)?,
removed_at: self.removed_at,
extra: self.extra,
})
}
}
impl WireSnapshot {
fn encode(material: &JoinMaterial) -> Result<Self, ListError> {
let mut channels = Vec::with_capacity(material.channels.len());
for channel in &material.channels {
channels.push(WireChannel::encode(channel)?);
}
Ok(Self {
owner: base64url::encode(&material.owner.to_bytes()),
owner_salt: encode_hex_32(&material.owner_salt, "owner_salt")?,
community_root: encode_hex_32(&material.community_root, "community_root")?,
root_epoch: material.root_epoch,
control_pk: material
.control_pk
.map(|key| base64url::encode(&key.to_bytes())),
control_root: match &material.control_root {
Some(root) => Some(encode_hex_32(root, "control_root")?),
None => None,
},
channels,
relays: material.relays.clone(),
name: material.name.clone(),
extra: material.extra.clone(),
})
}
fn decode(self, community_id: CommunityId) -> Result<JoinMaterial, ListError> {
let mut channels = Vec::with_capacity(self.channels.len());
for channel in self.channels {
channels.push(channel.decode()?);
}
Ok(JoinMaterial {
community_id,
owner: decode_public_key(&self.owner, "owner")?,
owner_salt: decode_hex_32_base64(&self.owner_salt, "owner_salt")?,
community_root: decode_hex_32_base64(&self.community_root, "community_root")?,
root_epoch: self.root_epoch,
control_pk: match self.control_pk {
Some(value) => Some(decode_public_key(&value, "control_pk")?),
None => None,
},
control_root: match self.control_root {
Some(value) => Some(decode_hex_32_base64(&value, "control_root")?),
None => None,
},
channels,
relays: self.relays,
name: self.name,
extra: self.extra,
})
}
}
impl WireChannel {
fn encode(grant: &ChannelGrant) -> Result<Self, ListError> {
Ok(Self {
id: base64url::encode(grant.id.as_bytes()),
key: match &grant.key {
Some(key) => Some(encode_hex_32(key, "channel key")?),
None => None,
},
epoch: grant.epoch,
name: grant.name.clone(),
extra: grant.extra.clone(),
})
}
fn decode(self) -> Result<ChannelGrant, ListError> {
Ok(ChannelGrant {
id: ChannelId::from_bytes(decode_base64(&self.id, "channel id")?),
key: match self.key {
Some(value) => Some(decode_hex_32_base64(&value, "channel key")?),
None => None,
},
epoch: self.epoch,
name: self.name,
extra: self.extra,
})
}
}
impl Serialize for CommunityList {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
WireList::encode(self)
.map_err(serde::ser::Error::custom)?
.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for CommunityList {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
WireList::deserialize(deserializer)?
.decode()
.map_err(serde::de::Error::custom)
}
}
impl Serialize for JoinMaterial {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
WireSnapshot::encode(self)
.map_err(serde::ser::Error::custom)?
.serialize(serializer)
}
} }
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
@@ -215,6 +588,7 @@ fn merge_entry(held: &mut CommunityListEntry, incoming: CommunityListEntry) {
held.seed = pick(&held.seed, &incoming.seed, Snapshot::Seed).clone(); held.seed = pick(&held.seed, &incoming.seed, Snapshot::Seed).clone();
held.current = pick(&held.current, &incoming.current, Snapshot::Current).clone(); held.current = pick(&held.current, &incoming.current, Snapshot::Current).clone();
union(&mut held.extra, incoming.extra); union(&mut held.extra, incoming.extra);
normalize(held);
} }
fn pick<'a>( fn pick<'a>(
@@ -238,21 +612,26 @@ fn pick<'a>(
held held
} }
pub(crate) fn union(into: &mut Extra, other: Extra) { fn normalize(entry: &mut CommunityListEntry) {
for (key, value) in other { entry.seed.community_id = entry.community_id;
let replace = match into.get(&key) { entry.current.community_id = entry.community_id;
Some(existing) => canonical(&value) < canonical(existing), normalize_snapshot(&mut entry.seed, &entry.current);
None => true,
};
if replace {
into.insert(key, value);
}
}
} }
pub(crate) fn canonical<T: Serialize>(value: &T) -> String { fn normalize_snapshot(seed: &mut JoinMaterial, current: &JoinMaterial) {
serde_json::to_string(value).unwrap_or_default() seed.community_id = current.community_id;
seed.name = current.name.clone();
seed.relays = current.relays.clone();
for seed_channel in &mut seed.channels {
if let Some(current_channel) = current
.channels
.iter()
.find(|channel| channel.id == seed_channel.id)
{
seed_channel.name = current_channel.name.clone();
}
}
} }
fn json_error(error: serde_json::Error) -> ListError { fn json_error(error: serde_json::Error) -> ListError {
@@ -358,7 +737,7 @@ mod tests {
} }
#[test] #[test]
fn a_tombstone_is_terminal_until_a_newer_join_outruns_it() { fn a_tombstone_is_terminal_and_the_entry_it_retires_is_never_written() {
let owner = Keys::generate().public_key(); let owner = Keys::generate().public_key();
let joined = entry( let joined = entry(
id(0x11), id(0x11),
@@ -378,22 +757,66 @@ mod tests {
// A stale device re-merging the entry cannot resurrect it. // A stale device re-merging the entry cannot resurrect it.
assert!(!merge(left.clone(), list(vec![joined.clone()])).is_live(&id(0x11))); assert!(!merge(left.clone(), list(vec![joined.clone()])).is_live(&id(0x11)));
// A re-join genuinely newer than the removal does. // The tombstone alone is written, so the entry's key material leaves the wire.
let rejoined = list(vec![entry( let written = serde_json::to_string(&left).expect("writes");
id(0x11), assert!(
material(id(0x11), owner, "Room", 0), !written.contains("\"entries\""),
material(id(0x11), owner, "Room", 0), "a retired entry is not written"
7_000, );
)]); let reparsed: CommunityList = serde_json::from_str(&written).expect("parses");
let live = merge(left, rejoined); assert_eq!(reparsed.tombstones.len(), 1);
assert!(live.is_live(&id(0x11))); assert!(!reparsed.is_live(&id(0x11)));
// A re-join genuinely newer than the removal does, and is written again.
let rejoined = merge(
reparsed,
list(vec![entry(
id(0x11),
material(id(0x11), owner, "Room", 0),
material(id(0x11), owner, "Room", 0),
7_000,
)]),
);
assert!(rejoined.is_live(&id(0x11)));
assert!(
serde_json::to_string(&rejoined)
.expect("writes")
.contains("\"entries\"")
);
// And the older removal is not re-applied on top of it. // And the older removal is not re-applied on top of it.
assert!(merge(live, removal(id(0x11), 6_000)).is_live(&id(0x11))); assert!(merge(rejoined, removal(id(0x11), 6_000)).is_live(&id(0x11)));
} }
#[test] #[test]
fn a_second_device_reconstructs_membership_from_13302() { fn frags_disagreement_resolves_to_the_larger_value_and_completeness_is_by_index() {
let two = CommunityList {
frags: 2,
..Default::default()
};
let three = CommunityList {
frags: 3,
..Default::default()
};
assert_eq!(
merge(two.clone(), three.clone()).frags,
3,
"the larger fragment count wins"
);
assert_eq!(merge(three.clone(), two).frags, 3);
assert!(!three.is_complete([0, 1]));
assert!(three.is_complete([0, 1, 2]));
assert!(three.is_complete([2, 1, 0]), "order does not matter");
assert!(
three.is_complete([0, 1, 2, 7]),
"indices at or above frags are out of range"
);
}
#[test]
fn a_second_device_reconstructs_membership_from_the_list() {
let me = Keys::generate(); let me = Keys::generate();
let owner = Keys::generate().public_key(); let owner = Keys::generate().public_key();
let mine = CommunityList { let mine = CommunityList {
@@ -416,23 +839,32 @@ mod tests {
removed_at: AT, removed_at: AT,
extra: Extra::default(), extra: Extra::default(),
}], }],
extra: Extra::default(), ..Default::default()
}; };
let event = build_list_event(&me, &mine).expect("builds"); let event = smol::block_on(build_list_event(&me, &mine, 1, AT_SECS)).expect("builds");
assert_eq!(event.kind, Kind::Custom(KIND_COMMUNITY_LIST)); assert_eq!(event.kind, Kind::Custom(KIND_COMMUNITY_LIST));
assert_eq!(parse_list_event(&me, &event).expect("parses"), mine); assert_eq!(fragment_index(&event).expect("a fragment index"), 1);
assert!( assert_eq!(
!parse_list_event(&me, &event) smol::block_on(parse_list_event(&me, &event)).expect("parses"),
.expect("parses") mine
.is_live(&id(0x33))
); );
// Only the member's own keys open it, and an unreadable list is "no news". // Only the member's own keys open it, and an unreadable list is "no news".
let stranger = Keys::generate(); let stranger = Keys::generate();
assert!(parse_list_event(&stranger, &event).is_err()); assert!(smol::block_on(parse_list_event(&stranger, &event)).is_err());
// Unknown fields survive the round trip, so a republish cannot wipe them. // A fragment with no `d` tag is not a fragment at all.
let untagged = EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), event.content.clone())
.finalize(&me)
.expect("signs");
assert!(matches!(
smol::block_on(parse_list_event(&me, &untagged)),
Err(ListError::Fragment(_))
));
// Unknown fields survive the round trip, so a republish cannot wipe them
// — including on a channel, where a dropped field destroys key material.
let mut held = mine.clone(); let mut held = mine.clone();
held.extra held.extra
.insert("future".to_owned(), serde_json::json!({"deep": [1, 2]})); .insert("future".to_owned(), serde_json::json!({"deep": [1, 2]}));
@@ -440,8 +872,21 @@ mod tests {
.current .current
.extra .extra
.insert("held_roots".to_owned(), serde_json::json!([{"epoch": 1}])); .insert("held_roots".to_owned(), serde_json::json!([{"epoch": 1}]));
let rebuilt = held.entries[0].current.channels = vec![ChannelGrant {
parse_list_event(&me, &build_list_event(&me, &held).expect("builds")).expect("parses"); id: ChannelId::from_bytes([0x9c; 32]),
key: Some("55".repeat(32)),
epoch: Epoch(2),
name: "staff".to_owned(),
extra: Extra::default(),
}];
held.entries[0].current.channels[0]
.extra
.insert("read_key".to_owned(), serde_json::json!("aa".repeat(32)));
let rebuilt = smol::block_on(parse_list_event(
&me,
&smol::block_on(build_list_event(&me, &held, 0, AT_SECS)).expect("builds"),
))
.expect("parses");
assert_eq!(rebuilt, held); assert_eq!(rebuilt, held);
// The write gate refuses an over-cap or oversized List before publishing. // The write gate refuses an over-cap or oversized List before publishing.
@@ -459,18 +904,146 @@ mod tests {
.collect(), .collect(),
); );
assert!(matches!( assert!(matches!(
build_list_event(&me, &crowded), smol::block_on(build_list_event(&me, &crowded, 0, AT_SECS)),
Err(ListError::TooManyMemberships(n)) if n == MAX_MEMBERSHIPS + 1 Err(ListError::TooManyMemberships(n)) if n == MAX_MEMBERSHIPS + 1
)); ));
let oversized = list(vec![entry( let oversized = list(vec![entry(
id(0x11), id(0x11),
material(id(0x11), owner, &"x".repeat(NIP44_MAX_PLAINTEXT), 0),
material(id(0x11), owner, "Room", 0), material(id(0x11), owner, "Room", 0),
material(id(0x11), owner, &"x".repeat(NIP44_MAX_PLAINTEXT), 0),
AT, AT,
)]); )]);
assert!(matches!(oversized.fits(), Err(ListError::Oversize(_)))); assert!(matches!(oversized.fits(), Err(ListError::Oversize(_))));
} }
/// The worked example in `examples.md` §6.2, verbatim. Five of its base64url
/// values leave non-zero trailing bits, so a strict decoder rejects them.
const EXAMPLE: &str = r#"{
"frags": 2,
"entries": [
{
"community_id": "PxpVK3nQ7sB1yTfWm4dLxZ0aRcE9uHgKjNvOpQrStUv",
"current": {
"owner": "nC7hQ2eRtYuIoPaSdFgHjKlZxCvBnM1qW3eR5tY7uI9",
"owner_salt": "qhEwR9tYuIoPaSdFgHjKlZxCvBnM1qW3eR5tY7uI0oP",
"community_root": "d70Xa1QwErTyUiOpAsDfGhJkLzXcVbNm2Qw4Er6Ty8U",
"root_epoch": 3,
"control_pk": "DU8vB4nM6qW1eR3tY5uI7oP9aS0dF2gH4jK6lZ8xC0v",
"channels": [
{ "id": "Ch1dQwErTyUiOpAsDfGhJkLzXcVbNm2Qw4Er6Ty8U0i",
"key": "K3yAsDfGhJkLzXcVbNm1Qw2Er3Ty4Ui5Op6As7Df8Gh",
"epoch": 2, "name": "staff" }
],
"relays": ["wss://relay.example.com"],
"name": "Example Community"
},
"added_at": 1719800000000
}
],
"tombstones": [
{ "community_id": "u9RfLmWx3PqZtYvBnKjHgFdSaQwErTyUiOp2C4E6G8I", "removed_at": 1722400000000 }
]
}"#;
#[test]
fn the_spec_example_parses_and_the_writer_canonicalizes_it() {
let parsed: CommunityList = serde_json::from_str(EXAMPLE).expect("the spec example parses");
assert_eq!(parsed.frags, 2);
assert_eq!(parsed.tombstones.len(), 1);
let entry = parsed.entries.first().expect("one membership");
assert_eq!(entry.current.root_epoch, Epoch(3));
assert_eq!(entry.current.name, "Example Community");
assert_eq!(entry.current.relays, ["wss://relay.example.com"]);
assert!(
entry.current.control_root.is_none(),
"a non-staff snapshot holds no control_root"
);
assert_eq!(
entry.seed, entry.current,
"an absent seed reads as equal to current"
);
let channel = entry.current.channels.first().expect("a private channel");
assert_eq!(channel.name, "staff");
assert_eq!(channel.epoch, Epoch(2));
assert!(channel.key.is_some());
let written = serde_json::to_string(&parsed).expect("writes");
assert!(written.contains("\"frags\":2"));
assert!(
!written.contains("\"seed\""),
"a seed equal to current is omitted"
);
assert!(
!written.contains("\"current\":{\"community_id\""),
"an embedded snapshot omits community_id and inherits the entry's"
);
// Every writer emits the canonical spelling, so a non-canonical input is
// stabilized here and two devices converge on identical bytes.
let value: serde_json::Value = serde_json::from_str(&written).expect("valid");
let owner = value["entries"][0]["current"]["owner"]
.as_str()
.expect("an owner");
assert_eq!(owner, base64url::encode(&entry.current.owner.to_bytes()));
assert_ne!(owner, "nC7hQ2eRtYuIoPaSdFgHjKlZxCvBnM1qW3eR5tY7uI9");
// Reading its own output is a fixed point.
let again: CommunityList = serde_json::from_str(&written).expect("parses");
assert_eq!(again, parsed);
assert_eq!(serde_json::to_string(&again).expect("writes"), written);
}
#[test]
fn a_rename_rewrites_the_seed_cosmetics_and_collapses_the_snapshot() {
let owner = Keys::generate().public_key();
let channel = ChannelId::from_bytes([0x9c; 32]);
let mut current = material(id(0x11), owner, "New name", 5);
current.channels = vec![ChannelGrant {
id: channel,
key: Some("55".repeat(32)),
epoch: Epoch(4),
name: "new channel".to_owned(),
extra: Extra::default(),
}];
let mut seed = material(id(0x11), owner, "Old name", 1);
seed.relays = vec!["wss://stale.example".to_owned()];
seed.channels = vec![ChannelGrant {
id: channel,
key: Some("55".repeat(32)),
epoch: Epoch(2),
name: "old channel".to_owned(),
extra: Extra::default(),
}];
let written =
serde_json::to_string(&list(vec![entry(id(0x11), seed, current.clone(), AT)]))
.expect("writes");
let value: serde_json::Value = serde_json::from_str(&written).expect("valid");
let written_seed = &value["entries"][0]["seed"];
assert_eq!(written_seed["name"], "New name");
assert_eq!(written_seed["relays"][0], "wss://relay.example");
assert_eq!(written_seed["channels"][0]["name"], "new channel");
assert_eq!(
written_seed["root_epoch"].as_u64(),
Some(1),
"the rewrite touches no key material"
);
// A seed that differs from current only in cosmetics is the same bytes
// after the rewrite, so it is omitted entirely.
let mut stale = material(id(0x11), owner, "Old name", 5);
stale.channels = current.channels.clone();
let collapsed = serde_json::to_string(&list(vec![entry(id(0x11), stale, current, AT)]))
.expect("writes");
assert!(!collapsed.contains("\"seed\""));
}
const AT: u64 = 1_719_800_000_000; const AT: u64 = 1_719_800_000_000;
const AT_SECS: u64 = AT / 1000;
} }
+190 -209
View File
@@ -4,7 +4,9 @@ pub mod list;
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use nostr_sdk::prelude::{Event, Keys, PublicKey, Timestamp, UnsignedEvent}; use nostr_sdk::prelude::{
AsyncGetPublicKey, AsyncSignEvent, Event, PublicKey, Timestamp, UnsignedEvent,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::cord01::{KIND_WRAP, SealForm, build_seal, open_wrap_at, wrap_seal_with}; use crate::cord01::{KIND_WRAP, SealForm, build_seal, open_wrap_at, wrap_seal_with};
@@ -114,17 +116,24 @@ pub struct CommunityGenesis {
pub wraps: Vec<Event>, pub wraps: Vec<Event>,
} }
pub fn genesis( pub async fn genesis<S>(
owner: &Keys, owner: &S,
metadata: &CommunityMetadata, metadata: &CommunityMetadata,
at_secs: u64, at_secs: u64,
) -> Result<CommunityGenesis> { ) -> Result<CommunityGenesis>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let metadata_content = encode_metadata(metadata)?; let metadata_content = encode_metadata(metadata)?;
let owner_salt = random_32()?; let owner_salt = random_32()?;
let owner_key = owner
.get_public_key_async()
.await
.map_err(|error| anyhow::anyhow!("signer: {error}"))?;
let identity = CommunityIdentity { let identity = CommunityIdentity {
community_id: community_id_of(&owner.public_key().to_bytes(), &owner_salt), community_id: community_id_of(&owner_key.to_bytes(), &owner_salt),
owner: owner.public_key(), owner: owner_key,
owner_salt, owner_salt,
}; };
@@ -167,7 +176,7 @@ pub fn genesis(
let mut wraps = Vec::with_capacity(editions.len()); let mut wraps = Vec::with_capacity(editions.len());
for edition in &editions { for edition in &editions {
wraps.push(seal_edition(edition, owner, &read, &signer, at_secs)?); wraps.push(seal_edition(edition, owner, &read, &signer, at_secs).await?);
} }
Ok(CommunityGenesis { Ok(CommunityGenesis {
@@ -211,12 +220,15 @@ pub struct Edition<'a> {
} }
impl ControlWriter { impl ControlWriter {
pub fn publish( pub async fn publish<S>(
&self, &self,
keys: &Keys, keys: &S,
edition: Edition<'_>, edition: Edition<'_>,
at_secs: u64, at_secs: u64,
) -> Result<(Event, EntityHead)> { ) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let rumor = build_edition(EditionFields { let rumor = build_edition(EditionFields {
author: self.author, author: self.author,
subkind: edition.subkind, subkind: edition.subkind,
@@ -231,20 +243,23 @@ impl ControlWriter {
}); });
let parsed = parse_edition(&rumor)?; let parsed = parse_edition(&rumor)?;
let wrap = seal_edition(&rumor, keys, &self.read, &self.signer, at_secs)?; let wrap = seal_edition(&rumor, keys, &self.read, &self.signer, at_secs).await?;
Ok((wrap, EntityHead::from(&parsed))) Ok((wrap, EntityHead::from(&parsed)))
} }
pub fn set_community_metadata( pub async fn set_community_metadata<S>(
&self, &self,
keys: &Keys, keys: &S,
community_id: &CommunityId, community_id: &CommunityId,
metadata: &CommunityMetadata, metadata: &CommunityMetadata,
head: Option<&EntityHead>, head: Option<&EntityHead>,
citation: Option<AuthorityCitation>, citation: Option<AuthorityCitation>,
at_secs: u64, at_secs: u64,
) -> Result<(Event, EntityHead)> { ) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let content = encode_metadata(metadata)?; let content = encode_metadata(metadata)?;
self.publish( self.publish(
@@ -258,17 +273,21 @@ impl ControlWriter {
}, },
at_secs, at_secs,
) )
.await
} }
pub fn set_channel_metadata( pub async fn set_channel_metadata<S>(
&self, &self,
keys: &Keys, keys: &S,
channel: &ChannelId, channel: &ChannelId,
metadata: &ChannelMetadata, metadata: &ChannelMetadata,
head: Option<&EntityHead>, head: Option<&EntityHead>,
citation: Option<AuthorityCitation>, citation: Option<AuthorityCitation>,
at_secs: u64, at_secs: u64,
) -> Result<(Event, EntityHead)> { ) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
if metadata.name.len() > MAX_NAME_BYTES { if metadata.name.len() > MAX_NAME_BYTES {
bail!("channel name exceeds {MAX_NAME_BYTES} bytes"); bail!("channel name exceeds {MAX_NAME_BYTES} bytes");
} }
@@ -286,16 +305,20 @@ impl ControlWriter {
}, },
at_secs, at_secs,
) )
.await
} }
pub fn set_role( pub async fn set_role<S>(
&self, &self,
keys: &Keys, keys: &S,
role: &Role, role: &Role,
head: Option<&EntityHead>, head: Option<&EntityHead>,
citation: Option<AuthorityCitation>, citation: Option<AuthorityCitation>,
at_secs: u64, at_secs: u64,
) -> Result<(Event, EntityHead)> { ) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
if role.name.len() > MAX_NAME_BYTES { if role.name.len() > MAX_NAME_BYTES {
bail!("role name exceeds {MAX_NAME_BYTES} bytes"); bail!("role name exceeds {MAX_NAME_BYTES} bytes");
} }
@@ -313,17 +336,21 @@ impl ControlWriter {
}, },
at_secs, at_secs,
) )
.await
} }
pub fn set_grant( pub async fn set_grant<S>(
&self, &self,
keys: &Keys, keys: &S,
community_id: &CommunityId, community_id: &CommunityId,
grant: &Grant, grant: &Grant,
head: Option<&EntityHead>, head: Option<&EntityHead>,
citation: Option<AuthorityCitation>, citation: Option<AuthorityCitation>,
at_secs: u64, at_secs: u64,
) -> Result<(Event, EntityHead)> { ) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let content = grant.to_content()?; let content = grant.to_content()?;
self.publish( self.publish(
@@ -337,17 +364,21 @@ impl ControlWriter {
}, },
at_secs, at_secs,
) )
.await
} }
pub fn set_banlist( pub async fn set_banlist<S>(
&self, &self,
keys: &Keys, keys: &S,
community_id: &CommunityId, community_id: &CommunityId,
banned: &BTreeSet<PublicKey>, banned: &BTreeSet<PublicKey>,
head: Option<&EntityHead>, head: Option<&EntityHead>,
citation: Option<AuthorityCitation>, citation: Option<AuthorityCitation>,
at_secs: u64, at_secs: u64,
) -> Result<(Event, EntityHead)> { ) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
if banned.len() > MAX_BANLIST { if banned.len() > MAX_BANLIST {
bail!("banlist exceeds {MAX_BANLIST} entries"); bail!("banlist exceeds {MAX_BANLIST} entries");
} }
@@ -366,19 +397,23 @@ impl ControlWriter {
}, },
at_secs, at_secs,
) )
.await
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn set_registry( pub async fn set_registry<S>(
&self, &self,
keys: &Keys, keys: &S,
community_id: &CommunityId, community_id: &CommunityId,
creator: &PublicKey, creator: &PublicKey,
links: &[PublicKey], links: &[PublicKey],
head: Option<&EntityHead>, head: Option<&EntityHead>,
citation: Option<AuthorityCitation>, citation: Option<AuthorityCitation>,
at_secs: u64, at_secs: u64,
) -> Result<(Event, EntityHead)> { ) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let entries: Vec<String> = links let entries: Vec<String> = links
.iter() .iter()
.take(MAX_REGISTRY_LINKS) .take(MAX_REGISTRY_LINKS)
@@ -397,20 +432,24 @@ impl ControlWriter {
}, },
at_secs, at_secs,
) )
.await
} }
/// The whole Pin List, in whichever of CORD-04 §7's two forms the Channel calls for. /// The whole Pin List, in whichever of CORD-04 §7's two forms the Channel calls for.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn set_pin_list( pub async fn set_pin_list<S>(
&self, &self,
keys: &Keys, keys: &S,
community_id: &CommunityId, community_id: &CommunityId,
channel: &ChannelId, channel: &ChannelId,
content: &str, content: &str,
head: Option<&EntityHead>, head: Option<&EntityHead>,
citation: Option<AuthorityCitation>, citation: Option<AuthorityCitation>,
at_secs: u64, at_secs: u64,
) -> Result<(Event, EntityHead)> { ) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
self.publish( self.publish(
keys, keys,
Edition { Edition {
@@ -422,6 +461,7 @@ impl ControlWriter {
}, },
at_secs, at_secs,
) )
.await
} }
} }
@@ -708,14 +748,17 @@ fn authorized_head<'a>(
selection.head.map(|index| authorized[index]) selection.head.map(|index| authorized[index])
} }
fn seal_edition( async fn seal_edition<S>(
edition: &UnsignedEvent, edition: &UnsignedEvent,
owner: &Keys, owner: &S,
read: &GroupKey, read: &GroupKey,
signer: &GroupKey, signer: &GroupKey,
at_secs: u64, at_secs: u64,
) -> Result<Event> { ) -> Result<Event>
let seal = build_seal(edition, SealForm::Plaintext, read, owner)?; where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let seal = build_seal(edition, SealForm::Plaintext, read, owner).await?;
let (wrap, _) = wrap_seal_with( let (wrap, _) = wrap_seal_with(
&seal, &seal,
@@ -731,15 +774,14 @@ fn seal_edition(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use nostr_memory::MemoryDatabase; use nostr_sdk::prelude::Keys;
use super::*; use super::*;
use crate::cord03::{self, build_message, seal_rumor}; use crate::cord03::{self, build_message, seal_rumor};
use crate::cord04::fold;
use crate::cord04::pins; use crate::cord04::pins;
use crate::cord04::roles::{Grant, MAX_BANLIST, MAX_ROLES_PER_MEMBER, Role, RoleScope}; use crate::cord04::roles::{Grant, MAX_BANLIST, MAX_ROLES_PER_MEMBER, Role, RoleScope};
use crate::derive::{channel_group_key, grant_locator}; use crate::derive::{channel_group_key, grant_locator};
use crate::store::{CommunityState, load_state, save_state}; use crate::store::CommunityState;
use crate::{Extra, RoleId}; use crate::{Extra, RoleId};
const AT: u64 = 1_700_000_000; const AT: u64 = 1_700_000_000;
@@ -768,70 +810,10 @@ mod tests {
} }
} }
#[test]
fn genesis_reopens_for_a_second_holder() {
let owner = Keys::generate();
let community_metadata = CommunityMetadata {
name: "coop".to_owned(),
relays: vec!["wss://relay.example".to_owned()],
..CommunityMetadata::default()
};
let minted = genesis(&owner, &community_metadata, AT).expect("mints");
assert!(minted.identity.verify(), "identity is self-certifying");
// Only what an invite hands over: the roots, the community id and the owner salt.
let (read, signer) = holder(&minted);
let editions = open_all(&minted.wraps, &read, &signer.pk());
assert_eq!(editions.len(), 2);
let community = &editions[0];
assert_eq!(community.subkind, vsk::COMMUNITY_METADATA);
assert_eq!(community.entity, *minted.identity.community_id.as_bytes());
assert_eq!(community.author, owner.public_key());
assert_eq!((community.version, community.prev), (1, None));
assert_eq!(
serde_json::from_str::<CommunityMetadata>(&community.content)
.expect("parses")
.name,
"coop"
);
let channel = &editions[1];
assert_eq!(channel.subkind, vsk::CHANNEL_METADATA);
assert_eq!(channel.entity, *minted.channel_id.as_bytes());
for edition in &editions {
let folded = fold(&[EditionMeta::from(edition)], 0, None);
assert_eq!(folded.head, Some(0));
assert!(
folded.anchored && !folded.gap,
"genesis anchors at its floor"
);
}
let state = CommunityState::from_genesis(&minted, &editions, AT * 1_000).expect("projects");
smol::block_on(async {
let database = MemoryDatabase::unbounded();
save_state(&database, &state).await.expect("saves");
let loaded = load_state(&database, &minted.identity.community_id)
.await
.expect("loads")
.expect("present");
assert_eq!(loaded.community_root, minted.community_root);
assert_eq!(loaded.control_root, Some(minted.control_root));
assert_eq!(loaded.channels.len(), 1);
assert_eq!(loaded.heads.len(), 2);
});
}
#[test] #[test]
fn metadata_and_channel_edits_reach_a_second_client() { fn metadata_and_channel_edits_reach_a_second_client() {
let owner = Keys::generate(); let owner = Keys::generate();
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints"); let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints");
let community_id = minted.identity.community_id; let community_id = minted.identity.community_id;
let owner_pk = owner.public_key(); let owner_pk = owner.public_key();
let (read, signer) = holder(&minted); let (read, signer) = holder(&minted);
@@ -860,33 +842,31 @@ mod tests {
.get(minted.channel_id.as_bytes()) .get(minted.channel_id.as_bytes())
.expect("head"); .expect("head");
let (community_wrap, _) = writer let (community_wrap, _) = smol::block_on(writer.set_community_metadata(
.set_community_metadata( &owner,
&owner, &community_id,
&community_id, &CommunityMetadata {
&CommunityMetadata { relays: vec!["wss://relay.example".to_owned()],
relays: vec!["wss://relay.example".to_owned()], ..metadata("coop two")
..metadata("coop two") },
}, Some(community_head),
Some(community_head), None,
None, AT + 1,
AT + 1, ))
) .expect("publishes");
.expect("publishes"); let (channel_wrap, _) = smol::block_on(writer.set_channel_metadata(
let (channel_wrap, _) = writer &owner,
.set_channel_metadata( &minted.channel_id,
&owner, &ChannelMetadata {
&minted.channel_id, name: "lobby".to_owned(),
&ChannelMetadata { private: false,
name: "lobby".to_owned(), ..ChannelMetadata::default()
private: false, },
..ChannelMetadata::default() Some(channel_head),
}, None,
Some(channel_head), AT + 2,
None, ))
AT + 2, .expect("publishes");
)
.expect("publishes");
let mut edited = genesis_editions.clone(); let mut edited = genesis_editions.clone();
edited.extend(open_all( edited.extend(open_all(
@@ -938,7 +918,7 @@ mod tests {
fn a_delegated_member_edits_metadata_only_under_its_own_grant() { fn a_delegated_member_edits_metadata_only_under_its_own_grant() {
let owner = Keys::generate(); let owner = Keys::generate();
let member = Keys::generate(); let member = Keys::generate();
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints"); let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints");
let community_id = minted.identity.community_id; let community_id = minted.identity.community_id;
let owner_pk = owner.public_key(); let owner_pk = owner.public_key();
let (read, signer) = holder(&minted); let (read, signer) = holder(&minted);
@@ -959,21 +939,20 @@ mod tests {
extra: Extra::default(), extra: Extra::default(),
}; };
let (role_wrap, _) = writer let (role_wrap, _) = smol::block_on(writer.publish(
.publish( &owner,
&owner, Edition {
Edition { subkind: vsk::ROLE,
subkind: vsk::ROLE, entity: *role_id.as_bytes(),
entity: *role_id.as_bytes(), content: &role.to_content().expect("serializes"),
content: &role.to_content().expect("serializes"), head: None,
head: None, citation: None,
citation: None, },
}, AT + 1,
AT + 1, ))
) .expect("publishes");
.expect("publishes"); let (grant_wrap, _) = smol::block_on(
let (grant_wrap, _) = writer writer.publish(
.publish(
&owner, &owner,
Edition { Edition {
subkind: vsk::GRANT, subkind: vsk::GRANT,
@@ -990,8 +969,9 @@ mod tests {
citation: None, citation: None,
}, },
AT + 2, AT + 2,
) ),
.expect("publishes"); )
.expect("publishes");
let mut base = open_all(&minted.wraps, &read, &signer.pk()); let mut base = open_all(&minted.wraps, &read, &signer.pk());
base.extend(open_all(&[role_wrap, grant_wrap], &read, &signer.pk())); base.extend(open_all(&[role_wrap, grant_wrap], &read, &signer.pk()));
@@ -1022,36 +1002,34 @@ mod tests {
}; };
let content = serde_json::to_string(&metadata("coop by mod")).expect("serializes"); let content = serde_json::to_string(&metadata("coop by mod")).expect("serializes");
let (uncited, _) = member_writer let (uncited, _) = smol::block_on(member_writer.publish(
.publish( &member,
&member, Edition {
Edition { subkind: vsk::COMMUNITY_METADATA,
subkind: vsk::COMMUNITY_METADATA, entity: *community_id.as_bytes(),
entity: *community_id.as_bytes(), content: &content,
content: &content, head: Some(head),
head: Some(head), citation: None,
citation: None, },
}, AT + 3,
AT + 3, ))
) .expect("publishes");
.expect("publishes"); let (cited, _) = smol::block_on(member_writer.publish(
let (cited, _) = member_writer &member,
.publish( Edition {
&member, subkind: vsk::COMMUNITY_METADATA,
Edition { entity: *community_id.as_bytes(),
subkind: vsk::COMMUNITY_METADATA, content: &content,
entity: *community_id.as_bytes(), head: Some(head),
content: &content, citation: Some(AuthorityCitation {
head: Some(head), entity: grant.entity,
citation: Some(AuthorityCitation { version: grant.version,
entity: grant.entity, hash: grant.self_hash,
version: grant.version, }),
hash: grant.self_hash, },
}), AT + 4,
}, ))
AT + 4, .expect("publishes");
)
.expect("publishes");
// Uncited, the edit claims an authority the member never showed. // Uncited, the edit claims an authority the member never showed.
let mut forged = base.clone(); let mut forged = base.clone();
@@ -1086,7 +1064,7 @@ mod tests {
#[test] #[test]
fn a_pin_list_folds_under_its_coordinate_for_a_second_client() { fn a_pin_list_folds_under_its_coordinate_for_a_second_client() {
let owner = Keys::generate(); let owner = Keys::generate();
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints"); let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints");
let community_id = minted.identity.community_id; let community_id = minted.identity.community_id;
let owner_pk = owner.public_key(); let owner_pk = owner.public_key();
let (read, signer) = holder(&minted); let (read, signer) = holder(&minted);
@@ -1105,7 +1083,7 @@ mod tests {
AT * 1_000, AT * 1_000,
None, None,
); );
let (wrap, _) = seal_rumor(&rumor, &group, &author, false).expect("seals"); let (wrap, _) = smol::block_on(seal_rumor(&rumor, &group, &author, false)).expect("seals");
let opened = cord03::open(&wrap, &group, &channel, ROOT_EPOCH) let opened = cord03::open(&wrap, &group, &channel, ROOT_EPOCH)
.expect("opens") .expect("opens")
.0; .0;
@@ -1127,17 +1105,16 @@ mod tests {
read: read.clone(), read: read.clone(),
signer: signer.clone(), signer: signer.clone(),
}; };
let (pin_wrap, _) = writer let (pin_wrap, _) = smol::block_on(writer.set_pin_list(
.set_pin_list( &owner,
&owner, &community_id,
&community_id, &channel,
&channel, &content,
&content, None,
None, None,
None, AT + 1,
AT + 1, ))
) .expect("publishes");
.expect("publishes");
let mut editions = open_all(&minted.wraps, &read, &signer.pk()); let mut editions = open_all(&minted.wraps, &read, &signer.pk());
editions.extend(open_all(&[pin_wrap], &read, &signer.pk())); editions.extend(open_all(&[pin_wrap], &read, &signer.pk()));
@@ -1172,7 +1149,7 @@ mod tests {
#[test] #[test]
fn the_timer_is_never_guessed_and_the_write_caps_hold() { fn the_timer_is_never_guessed_and_the_write_caps_hold() {
let owner = Keys::generate(); let owner = Keys::generate();
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints"); let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints");
let community_id = minted.identity.community_id; let community_id = minted.identity.community_id;
let owner_pk = owner.public_key(); let owner_pk = owner.public_key();
let (read, signer) = holder(&minted); let (read, signer) = holder(&minted);
@@ -1187,10 +1164,16 @@ mod tests {
&owner_pk, &owner_pk,
&community_id, &community_id,
&open_all( &open_all(
&[writer &[smol::block_on(writer.set_community_metadata(
.set_community_metadata(&owner, &community_id, metadata, None, None, AT + 1) &owner,
.expect("publishes") &community_id,
.0], metadata,
None,
None,
AT + 1,
))
.expect("publishes")
.0],
&writer.read, &writer.read,
&writer.signer.pk(), &writer.signer.pk(),
), ),
@@ -1226,8 +1209,7 @@ mod tests {
.map(|_| Keys::generate().public_key()) .map(|_| Keys::generate().public_key())
.collect(); .collect();
assert!( assert!(
writer smol::block_on(writer.set_banlist(&owner, &community_id, &banned, None, None, AT + 2))
.set_banlist(&owner, &community_id, &banned, None, None, AT + 2)
.is_err() .is_err()
); );
@@ -1242,20 +1224,19 @@ mod tests {
assert!(grant.to_content().is_err()); assert!(grant.to_content().is_err());
assert!( assert!(
writer smol::block_on(writer.set_channel_metadata(
.set_channel_metadata( &owner,
&owner, &minted.channel_id,
&minted.channel_id, &ChannelMetadata {
&ChannelMetadata { name: "x".repeat(MAX_NAME_BYTES + 1),
name: "x".repeat(MAX_NAME_BYTES + 1), private: false,
private: false, ..ChannelMetadata::default()
..ChannelMetadata::default() },
}, None,
None, None,
None, AT + 3,
AT + 3, ))
) .is_err()
.is_err()
); );
} }
} }
+19 -96
View File
@@ -1,18 +1,16 @@
use std::cmp::Reverse; use std::cmp::Reverse;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::fmt;
use anyhow::Result; use anyhow::Result;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use crate::cord01::{ use crate::cord01::{
KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, StreamError, build_rumor_ms, KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, build_rumor_ms, build_seal,
build_seal, channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict, channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict, wrap_seal,
wrap_seal,
};
use crate::cord04::{
AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag,
}; };
use crate::cord04::{AuthorityCitation, canonical_decimal, citation_tag};
pub use crate::cords::rumor::RumorError as ChatError;
use crate::cords::rumor::{optional_citation, pubkey, tag, value};
use crate::derive::channel_group_key; use crate::derive::channel_group_key;
use crate::{ChannelId, Epoch, GroupKey, decode_hex_32}; use crate::{ChannelId, Epoch, GroupKey, decode_hex_32};
@@ -36,42 +34,6 @@ const TAG_TARGET_AUTHOR: &str = "p";
const TAG_EXPIRATION: &str = "expiration"; const TAG_EXPIRATION: &str = "expiration";
const TAG_TIMER: &str = "timer"; const TAG_TIMER: &str = "timer";
#[derive(Debug)]
pub enum ChatError {
Stream(StreamError),
NotEncryptedSealed,
UnknownKind(u16),
MissingTag(&'static str),
DuplicateTag(&'static str),
BadTag(&'static str),
/// Neither a delete nor a timer notice may be erased by the policy it carries.
ExemptExpiration,
}
impl fmt::Display for ChatError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ChatError::Stream(error) => write!(f, "stream: {error}"),
ChatError::NotEncryptedSealed => write!(f, "chat rumor must ride an encrypted seal"),
ChatError::UnknownKind(kind) => write!(f, "not a chat rumor kind: {kind}"),
ChatError::MissingTag(name) => write!(f, "missing chat tag: {name}"),
ChatError::DuplicateTag(name) => write!(f, "duplicate chat tag: {name}"),
ChatError::BadTag(name) => write!(f, "malformed chat tag: {name}"),
ChatError::ExemptExpiration => {
write!(f, "a delete or timer notice must not carry an expiration")
}
}
}
}
impl std::error::Error for ChatError {}
impl From<StreamError> for ChatError {
fn from(error: StreamError) -> Self {
ChatError::Stream(error)
}
}
/// A chat event another chat event refers to: a quote, a comment's parent, a reaction's target. /// A chat event another chat event refers to: a quote, a comment's parent, a reaction's target.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReplyRef { pub struct ReplyRef {
@@ -292,19 +254,22 @@ pub fn build_typing(
} }
/// `ephemeral` picks the 21059 wrap, which relays must not store. /// `ephemeral` picks the 21059 wrap, which relays must not store.
pub fn seal_rumor( pub async fn seal_rumor<S>(
rumor: &UnsignedEvent, rumor: &UnsignedEvent,
group: &GroupKey, group: &GroupKey,
author: &Keys, author: &S,
ephemeral: bool, ephemeral: bool,
) -> Result<(Event, Keys), ChatError> { ) -> Result<(Event, Keys), ChatError>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let kind = rumor.kind.as_u16(); let kind = rumor.kind.as_u16();
if !is_chat_kind(kind) { if !is_chat_kind(kind) {
return Err(ChatError::UnknownKind(kind)); return Err(ChatError::UnknownKind(kind));
} }
let seal = build_seal(rumor, SealForm::Encrypted, group, author)?; let seal = build_seal(rumor, SealForm::Encrypted, group, author).await?;
let wrap_kind = if ephemeral { let wrap_kind = if ephemeral {
KIND_WRAP_EPHEMERAL KIND_WRAP_EPHEMERAL
} else { } else {
@@ -576,16 +541,6 @@ fn optional_kind(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<u16
.map_err(|_| ChatError::BadTag(name)) .map_err(|_| ChatError::BadTag(name))
} }
fn optional_citation(rumor: &UnsignedEvent) -> Result<Option<AuthorityCitation>, ChatError> {
let Some(fields) = tag(rumor, TAG_CITATION)? else {
return Ok(None);
};
citation_from(fields)
.map(Some)
.ok_or(ChatError::BadTag(TAG_CITATION))
}
pub fn expiration_of(rumor: &UnsignedEvent) -> Result<Option<Timestamp>, ChatError> { pub fn expiration_of(rumor: &UnsignedEvent) -> Result<Option<Timestamp>, ChatError> {
let Some(fields) = tag(rumor, TAG_EXPIRATION)? else { let Some(fields) = tag(rumor, TAG_EXPIRATION)? else {
return Ok(None); return Ok(None);
@@ -611,51 +566,16 @@ fn reply_tag(name: &str, reply: &ReplyRef) -> Tag {
) )
} }
fn tag<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<Option<&'a [String]>, ChatError> {
let mut found: Option<&[String]> = None;
for candidate in rumor.tags.iter() {
let fields = candidate.as_slice();
if fields.first().map(String::as_str) != Some(name) {
continue;
}
if found.is_some() {
return Err(ChatError::DuplicateTag(name));
}
found = Some(fields);
}
Ok(found)
}
fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, ChatError> {
fields
.get(1)
.map(String::as_str)
.ok_or(ChatError::BadTag(name))
}
fn hex_id(fields: &[String], name: &'static str) -> Result<EventId, ChatError> { fn hex_id(fields: &[String], name: &'static str) -> Result<EventId, ChatError> {
let bytes = decode_hex_32(value(fields, name)?).map_err(|_| ChatError::BadTag(name))?; let bytes = decode_hex_32(value(fields, name)?).map_err(|_| ChatError::BadTag(name))?;
EventId::from_slice(&bytes).map_err(|_| ChatError::BadTag(name)) EventId::from_slice(&bytes).map_err(|_| ChatError::BadTag(name))
} }
fn pubkey(hex: &str, name: &'static str) -> Result<PublicKey, ChatError> {
let bytes = decode_hex_32(hex).map_err(|_| ChatError::BadTag(name))?;
PublicKey::from_slice(&bytes).map_err(|_| ChatError::BadTag(name))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::cord01::StreamError;
const SECRET: [u8; 32] = [0x2du8; 32]; const SECRET: [u8; 32] = [0x2du8; 32];
const AT: u64 = 1_700_000_000_417; const AT: u64 = 1_700_000_000_417;
@@ -674,7 +594,9 @@ mod tests {
} }
fn sealed(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys) -> Event { fn sealed(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys) -> Event {
seal_rumor(rumor, group, author, false).expect("seals").0 smol::block_on(seal_rumor(rumor, group, author, false))
.expect("seals")
.0
} }
fn read(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys, epoch: Epoch) -> ChatRumor { fn read(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys, epoch: Epoch) -> ChatRumor {
@@ -949,7 +871,8 @@ mod tests {
// Chat is encrypted-seal only (CORD-02 §5), and a retired kind is not a // Chat is encrypted-seal only (CORD-02 §5), and a retired kind is not a
// chat rumor however well-formed it looks. // chat rumor however well-formed it looks.
let seal = build_seal(&plain, SealForm::Plaintext, &group, &alice).expect("seals"); let seal =
smol::block_on(build_seal(&plain, SealForm::Plaintext, &group, &alice)).expect("seals");
let (wrap, _) = wrap_seal( let (wrap, _) = wrap_seal(
&seal, &seal,
&group, &group,
@@ -971,7 +894,7 @@ mod tests {
AT, AT,
); );
assert!(matches!( assert!(matches!(
seal_rumor(&ghost, &group, &alice, false), smol::block_on(seal_rumor(&ghost, &group, &alice, false)),
Err(ChatError::UnknownKind(3300)) Err(ChatError::UnknownKind(3300))
)); ));
+4 -4
View File
@@ -121,7 +121,7 @@ fn signing_bytes(
bytes bytes
} }
pub fn edition_hash( fn edition_hash(
entity: &[u8; 32], entity: &[u8; 32],
version: u64, version: u64,
prev: Option<&[u8; 32]>, prev: Option<&[u8; 32]>,
@@ -246,14 +246,14 @@ impl From<&ParsedEdition> for EditionMeta {
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FoldResult { struct FoldResult {
pub head: Option<usize>, pub head: Option<usize>,
pub gap: bool, pub gap: bool,
pub anchored: bool, pub anchored: bool,
} }
/// The highest version whose chain is intact, given a held floor. /// The highest version whose chain is intact, given a held floor.
pub fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>) -> FoldResult { fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>) -> FoldResult {
let mut by_version: BTreeMap<u64, usize> = BTreeMap::new(); let mut by_version: BTreeMap<u64, usize> = BTreeMap::new();
for (index, edition) in editions.iter().enumerate() { for (index, edition) in editions.iter().enumerate() {
@@ -311,7 +311,7 @@ pub fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>)
} }
/// The highest version overall, ignoring contiguity. /// The highest version overall, ignoring contiguity.
pub fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> { fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> {
editions editions
.iter() .iter()
.enumerate() .enumerate()
+6 -4
View File
@@ -583,7 +583,7 @@ mod tests {
at_ms, at_ms,
None, None,
); );
let (wrap, _) = seal_rumor(&rumor, &group(), author, false).expect("seals"); let (wrap, _) = smol::block_on(seal_rumor(&rumor, &group(), author, false)).expect("seals");
open(&wrap, &group(), &channel(), Epoch(0)).expect("opens") open(&wrap, &group(), &channel(), Epoch(0)).expect("opens")
} }
@@ -699,7 +699,7 @@ mod tests {
AT_MS + 5_000, AT_MS + 5_000,
None, None,
); );
let (wrap, _) = seal_rumor(&edit, &group(), &author, false).expect("seals"); let (wrap, _) = smol::block_on(seal_rumor(&edit, &group(), &author, false)).expect("seals");
let (edit_opened, _) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens"); let (edit_opened, _) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
let refreshed = with_proven_edit(&entry, &edit_opened, &group(), &channel()); let refreshed = with_proven_edit(&entry, &edit_opened, &group(), &channel());
@@ -718,7 +718,8 @@ mod tests {
AT_MS + 6_000, AT_MS + 6_000,
None, None,
); );
let (wrap, _) = seal_rumor(&hijack, &group(), &stranger, false).expect("seals"); let (wrap, _) =
smol::block_on(seal_rumor(&hijack, &group(), &stranger, false)).expect("seals");
let (hijack_opened, _) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens"); let (hijack_opened, _) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
let unchanged = with_proven_edit(&entry, &hijack_opened, &group(), &channel()); let unchanged = with_proven_edit(&entry, &hijack_opened, &group(), &channel());
@@ -805,7 +806,8 @@ mod tests {
None, None,
AT_MS + 1_000, AT_MS + 1_000,
); );
let (wrap, _) = seal_rumor(&delete, &group(), author_keys, false).expect("seals"); let (wrap, _) =
smol::block_on(seal_rumor(&delete, &group(), author_keys, false)).expect("seals");
let (_, rumor) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens"); let (_, rumor) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
assert_eq!( assert_eq!(
+3 -3
View File
@@ -100,7 +100,7 @@ pub struct Role {
} }
impl Role { impl Role {
pub fn parse(content: &str) -> Option<Self> { fn parse(content: &str) -> Option<Self> {
serde_json::from_str(content).ok() serde_json::from_str(content).ok()
} }
@@ -122,7 +122,7 @@ pub struct Grant {
} }
impl Grant { impl Grant {
pub fn parse(content: &str) -> Option<Self> { fn parse(content: &str) -> Option<Self> {
serde_json::from_str(content).ok() serde_json::from_str(content).ok()
} }
@@ -135,7 +135,7 @@ impl Grant {
} }
} }
pub fn parse_banlist(content: &str) -> Option<Vec<PublicKey>> { fn parse_banlist(content: &str) -> Option<Vec<PublicKey>> {
let entries: Vec<String> = serde_json::from_str(content).ok()?; let entries: Vec<String> = serde_json::from_str(content).ok()?;
let mut banned = Vec::with_capacity(entries.len()); let mut banned = Vec::with_capacity(entries.len());
+44 -21
View File
@@ -11,10 +11,10 @@ use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::cord01::{self, NIP44_MAX_PLAINTEXT, StreamError}; use crate::cord01::{self, NIP44_MAX_PLAINTEXT, StreamError};
use crate::cord02::list::{canonical, union};
use crate::cord02::{ImageRef, MAX_RELAYS}; use crate::cord02::{ImageRef, MAX_RELAYS};
use crate::cord04::{TAG_SUBKIND, vsk}; use crate::cord04::{TAG_SUBKIND, vsk};
use crate::derive::{TOKEN_LEN, verify_community_id}; use crate::derive::{TOKEN_LEN, verify_community_id};
use crate::utils::{canonical, union};
use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32}; use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32};
pub const KIND_BUNDLE: u16 = 33301; pub const KIND_BUNDLE: u16 = 33301;
@@ -447,16 +447,19 @@ pub fn parse_link(input: &str) -> Result<ParsedInviteLink, InviteError> {
}) })
} }
pub fn build_direct_invite( pub async fn build_direct_invite<S>(
inviter: &Keys, inviter: &S,
recipient: &PublicKey, recipient: &PublicKey,
invite: &CommunityInvite, invite: &CommunityInvite,
) -> Result<Event, InviteError> { ) -> Result<Event, InviteError>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44,
{
invite.validate()?; invite.validate()?;
let json = serde_json::to_string(invite).map_err(json_error)?; let json = serde_json::to_string(invite).map_err(json_error)?;
let rumor = EventBuilder::new(Kind::Custom(KIND_DIRECT_INVITE), json) let author = inviter.get_public_key_async().await.map_err(crypto_error)?;
.finalize_unsigned(inviter.public_key()); let rumor = EventBuilder::new(Kind::Custom(KIND_DIRECT_INVITE), json).finalize_unsigned(author);
let mut tags = vec![Tag::custom("k", [KIND_DIRECT_INVITE.to_string()])]; let mut tags = vec![Tag::custom("k", [KIND_DIRECT_INVITE.to_string()])];
@@ -469,15 +472,22 @@ pub fn build_direct_invite(
GiftWrapBuilder::new(*recipient, rumor) GiftWrapBuilder::new(*recipient, rumor)
.extra_tags(tags) .extra_tags(tags)
.finalize(inviter) .finalize_async(inviter)
.await
.map_err(crypto_error) .map_err(crypto_error)
} }
pub fn unwrap_direct_invite( /// The NIP-59 unwrap is `Sized`-bounded in the SDK, so this stays `Sized` too.
pub async fn unwrap_direct_invite<S>(
wrap: &Event, wrap: &Event,
recipient: &Keys, recipient: &S,
) -> Result<(PublicKey, CommunityInvite), InviteError> { ) -> Result<(PublicKey, CommunityInvite), InviteError>
let unwrapped = UnwrappedGift::from_gift_wrap(recipient, wrap).map_err(crypto_error)?; where
S: AsyncNip44,
{
let unwrapped = UnwrappedGift::from_gift_wrap_async(recipient, wrap)
.await
.map_err(crypto_error)?;
if unwrapped.rumor.kind.as_u16() != KIND_DIRECT_INVITE { if unwrapped.rumor.kind.as_u16() != KIND_DIRECT_INVITE {
return Err(InviteError::BadEvent("rumor is not a direct invite")); return Err(InviteError::BadEvent("rumor is not a direct invite"));
@@ -590,25 +600,32 @@ pub fn merge_invite_lists(held: InviteList, incoming: InviteList) -> InviteList
} }
} }
pub fn build_invite_list(keys: &Keys, list: &InviteList) -> Result<Event, InviteError> { pub async fn build_invite_list<S>(keys: &S, list: &InviteList) -> Result<Event, InviteError>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
list.fits()?; list.fits()?;
let json = serde_json::to_string(list).map_err(json_error)?; let json = serde_json::to_string(list).map_err(json_error)?;
let content = cord01::seal_to_self(keys, json.as_bytes())?; let content = cord01::seal_to_self(keys, &json).await?;
EventBuilder::new(Kind::Custom(KIND_INVITE_LIST), content) EventBuilder::new(Kind::Custom(KIND_INVITE_LIST), content)
.finalize(keys) .finalize_async(keys)
.await
.map_err(crypto_error) .map_err(crypto_error)
} }
pub fn parse_invite_list(keys: &Keys, event: &Event) -> Result<InviteList, InviteError> { pub async fn parse_invite_list<S>(keys: &S, event: &Event) -> Result<InviteList, InviteError>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
if event.kind.as_u16() != KIND_INVITE_LIST { if event.kind.as_u16() != KIND_INVITE_LIST {
return Err(InviteError::Kind(event.kind.as_u16())); return Err(InviteError::Kind(event.kind.as_u16()));
} }
let json = cord01::open_to_self(keys, &event.content)?; let json = cord01::open_to_self(keys, &event.content).await?;
serde_json::from_slice(&json).map_err(json_error) serde_json::from_str(&json).map_err(json_error)
} }
/// An entry is immutable once minted, so two copies should agree. /// An entry is immutable once minted, so two copies should agree.
@@ -920,7 +937,12 @@ mod tests {
let recipient = Keys::generate(); let recipient = Keys::generate();
let invite = bundle(); let invite = bundle();
let wrap = build_direct_invite(&inviter, &recipient.public_key(), &invite).expect("builds"); let wrap = smol::block_on(build_direct_invite(
&inviter,
&recipient.public_key(),
&invite,
))
.expect("builds");
assert_eq!(wrap.kind, Kind::GiftWrap); assert_eq!(wrap.kind, Kind::GiftWrap);
assert_ne!( assert_ne!(
wrap.pubkey, wrap.pubkey,
@@ -932,13 +954,14 @@ mod tests {
"the k tag is what makes an invite indexable" "the k tag is what makes an invite indexable"
); );
let (sender, opened) = unwrap_direct_invite(&wrap, &recipient).expect("unwraps"); let (sender, opened) =
smol::block_on(unwrap_direct_invite(&wrap, &recipient)).expect("unwraps");
assert_eq!(sender, inviter.public_key()); assert_eq!(sender, inviter.public_key());
assert_eq!(opened.community_id, invite.community_id); assert_eq!(opened.community_id, invite.community_id);
// Somebody else's wrap is not ours to open... // Somebody else's wrap is not ours to open...
let stranger = Keys::generate(); let stranger = Keys::generate();
assert!(unwrap_direct_invite(&wrap, &stranger).is_err()); assert!(smol::block_on(unwrap_direct_invite(&wrap, &stranger)).is_err());
// ...and a wrap that opens to some other kind is not an invite. // ...and a wrap that opens to some other kind is not an invite.
let rumor = EventBuilder::new(Kind::Custom(crate::cord03::KIND_MESSAGE), "hello") let rumor = EventBuilder::new(Kind::Custom(crate::cord03::KIND_MESSAGE), "hello")
@@ -947,7 +970,7 @@ mod tests {
.finalize(&recipient) .finalize(&recipient)
.expect("wraps"); .expect("wraps");
assert!(matches!( assert!(matches!(
unwrap_direct_invite(&wrap, &recipient), smol::block_on(unwrap_direct_invite(&wrap, &recipient)),
Err(InviteError::BadEvent(_)) Err(InviteError::BadEvent(_))
)); ));
} }
+100 -74
View File
@@ -2,9 +2,10 @@ use std::collections::{BTreeMap, BTreeSet};
use std::fmt; use std::fmt;
use anyhow::Result; use anyhow::Result;
use data_encoding::HEXLOWER; use data_encoding::{BASE64, HEXLOWER};
use nostr::nips::nip44::v2::ConversationKey; use nostr_sdk::prelude::{
use nostr_sdk::prelude::{Event, Keys, PublicKey, SecretKey, Tag, Timestamp, UnsignedEvent}; AsyncGetPublicKey, AsyncNip44, AsyncSignEvent, Event, PublicKey, Tag, Timestamp, UnsignedEvent,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::cord01::{self, KIND_SEAL_PLAINTEXT, OpenedStream, SealForm, StreamError}; use crate::cord01::{self, KIND_SEAL_PLAINTEXT, OpenedStream, SealForm, StreamError};
@@ -298,34 +299,48 @@ pub fn blob_locator(
)) ))
} }
pub fn build_blob( pub async fn build_blob<S>(
rotator: &Keys, rotator: &S,
recipient: &PublicKey, recipient: &PublicKey,
scope: RekeyScope, scope: RekeyScope,
epoch: Epoch, epoch: Epoch,
new_key: &[u8; 32], new_key: &[u8; 32],
control_pk: Option<&[u8; 32]>, control_pk: Option<&[u8; 32]>,
control_root: Option<&[u8; 32]>, control_root: Option<&[u8; 32]>,
) -> Result<RekeyBlob, RekeyError> { ) -> Result<RekeyBlob, RekeyError>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
let plaintext = encode_blob_plaintext(scope, epoch, new_key, control_pk, control_root)?; let plaintext = encode_blob_plaintext(scope, epoch, new_key, control_pk, control_root)?;
let rotator_pk = rotator.get_public_key_async().await.map_err(crypto_error)?;
let wrapped = rotator
.nip44_encrypt_async(recipient, &BASE64.encode(&plaintext))
.await
.map_err(crypto_error)?;
Ok(RekeyBlob { Ok(RekeyBlob {
locator: blob_locator(&rotator.public_key(), recipient, scope, epoch), locator: blob_locator(&rotator_pk, recipient, scope, epoch),
wrapped: seal_to(rotator.secret_key(), recipient, &plaintext)?, wrapped,
}) })
} }
pub fn open_blob( pub async fn open_blob<S>(
recipient: &Keys, recipient: &S,
rotator: &PublicKey, rotator: &PublicKey,
scope: RekeyScope, scope: RekeyScope,
epoch: Epoch, epoch: Epoch,
blob: &RekeyBlob, blob: &RekeyBlob,
community_id: &CommunityId, community_id: &CommunityId,
) -> Result<KeyDelivery, RekeyError> { ) -> Result<KeyDelivery, RekeyError>
let conversation = where
ConversationKey::derive(recipient.secret_key(), rotator).map_err(crypto_error)?; S: AsyncNip44 + ?Sized,
let plaintext = cord01::open_bytes(&conversation, &blob.wrapped)?; {
let text = recipient
.nip44_decrypt_async(rotator, &blob.wrapped)
.await
.map_err(crypto_error)?;
let plaintext = BASE64.decode(text.as_bytes()).map_err(crypto_error)?;
parse_blob_plaintext(&plaintext, scope, epoch, community_id) parse_blob_plaintext(&plaintext, scope, epoch, community_id)
} }
@@ -341,15 +356,6 @@ pub fn find_my_blobs<'a>(
blobs.iter().filter(move |blob| blob.locator == wanted) blobs.iter().filter(move |blob| blob.locator == wanted)
} }
fn seal_to(
secret: &SecretKey,
recipient: &PublicKey,
plaintext: &[u8],
) -> Result<String, RekeyError> {
let conversation = ConversationKey::derive(secret, recipient).map_err(crypto_error)?;
Ok(cord01::seal_bytes(&conversation, plaintext)?)
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct RekeyChunk { pub struct RekeyChunk {
pub rotator: PublicKey, pub rotator: PublicKey,
@@ -598,8 +604,8 @@ pub fn build_rekey_rumor(
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn build_rekey_chunks( pub async fn build_rekey_chunks<S>(
rotator: &Keys, rotator: &S,
group: &GroupKey, group: &GroupKey,
scope: RekeyScope, scope: RekeyScope,
new_epoch: Epoch, new_epoch: Epoch,
@@ -609,7 +615,11 @@ pub fn build_rekey_chunks(
citation: Option<&AuthorityCitation>, citation: Option<&AuthorityCitation>,
severed: bool, severed: bool,
at_secs: u64, at_secs: u64,
) -> Result<Vec<Event>, RekeyError> { ) -> Result<Vec<Event>, RekeyError>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let rotator_key = rotator.get_public_key_async().await.map_err(crypto_error)?;
let mut groups: Vec<&[RekeyBlob]> = blobs.chunks(MAX_REKEY_BLOBS_PER_EVENT).collect(); let mut groups: Vec<&[RekeyBlob]> = blobs.chunks(MAX_REKEY_BLOBS_PER_EVENT).collect();
if groups.is_empty() { if groups.is_empty() {
@@ -621,7 +631,7 @@ pub fn build_rekey_chunks(
for (index, group_blobs) in groups.into_iter().enumerate() { for (index, group_blobs) in groups.into_iter().enumerate() {
let rumor = build_rekey_rumor( let rumor = build_rekey_rumor(
rotator.public_key(), rotator_key,
scope, scope,
new_epoch, new_epoch,
prev_epoch, prev_epoch,
@@ -633,7 +643,7 @@ pub fn build_rekey_chunks(
at_secs, at_secs,
)?; )?;
let seal = cord01::build_seal(&rumor, SealForm::Encrypted, group, rotator)?; let seal = cord01::build_seal(&rumor, SealForm::Encrypted, group, rotator).await?;
let (wrap, _) = cord01::wrap_seal( let (wrap, _) = cord01::wrap_seal(
&seal, &seal,
group, group,
@@ -731,14 +741,17 @@ pub fn dissolved_tombstone_rumor(
) )
} }
pub fn seal_dissolved( pub async fn seal_dissolved<S>(
rumor: &UnsignedEvent, rumor: &UnsignedEvent,
community_id: &CommunityId, community_id: &CommunityId,
owner: &Keys, owner: &S,
at_secs: u64, at_secs: u64,
) -> Result<Event, RekeyError> { ) -> Result<Event, RekeyError>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let group = dissolved_group_key(community_id).map_err(crypto_error)?; let group = dissolved_group_key(community_id).map_err(crypto_error)?;
let seal = cord01::build_seal(rumor, SealForm::Plaintext, &group, owner)?; let seal = cord01::build_seal(rumor, SealForm::Plaintext, &group, owner).await?;
let (wrap, _) = cord01::wrap_seal( let (wrap, _) = cord01::wrap_seal(
&seal, &seal,
&group, &group,
@@ -858,6 +871,8 @@ fn crypto_error(error: impl fmt::Display) -> RekeyError {
mod tests { mod tests {
use std::collections::BTreeSet; use std::collections::BTreeSet;
use nostr_sdk::prelude::Keys;
use super::*; use super::*;
use crate::cord01::KIND_WRAP; use crate::cord01::KIND_WRAP;
use crate::cord02::{ use crate::cord02::{
@@ -912,16 +927,16 @@ mod tests {
let scope = RekeyScope::Channel(channel()); let scope = RekeyScope::Channel(channel());
let open = |keys: &Keys, scope: RekeyScope, epoch: Epoch, blob: &RekeyBlob| { let open = |keys: &Keys, scope: RekeyScope, epoch: Epoch, blob: &RekeyBlob| {
open_blob( smol::block_on(open_blob(
keys, keys,
&rotator.public_key(), &rotator.public_key(),
scope, scope,
epoch, epoch,
blob, blob,
&community_id, &community_id,
) ))
}; };
let blob = build_blob( let blob = smol::block_on(build_blob(
&rotator, &rotator,
&recipient.public_key(), &recipient.public_key(),
scope, scope,
@@ -929,7 +944,7 @@ mod tests {
&key, &key,
None, None,
None, None,
) ))
.expect("builds"); .expect("builds");
assert_eq!( assert_eq!(
@@ -962,7 +977,7 @@ mod tests {
.to_bytes(); .to_bytes();
let base = |pk: Option<&[u8; 32]>, root: Option<&[u8; 32]>| { let base = |pk: Option<&[u8; 32]>, root: Option<&[u8; 32]>| {
build_blob( smol::block_on(build_blob(
&rotator, &rotator,
&recipient.public_key(), &recipient.public_key(),
RekeyScope::Base, RekeyScope::Base,
@@ -970,7 +985,7 @@ mod tests {
&key, &key,
pk, pk,
root, root,
) ))
.expect("builds") .expect("builds")
}; };
@@ -1043,7 +1058,7 @@ mod tests {
let community_id = community(); let community_id = community();
let blob_for = |recipient: &Keys, key: [u8; 32]| { let blob_for = |recipient: &Keys, key: [u8; 32]| {
build_blob( smol::block_on(build_blob(
&rotator, &rotator,
&recipient.public_key(), &recipient.public_key(),
scope, scope,
@@ -1051,7 +1066,7 @@ mod tests {
&key, &key,
None, None,
None, None,
) ))
.expect("builds") .expect("builds")
}; };
let mine = blob_for(&me, [0xAA; 32]); let mine = blob_for(&me, [0xAA; 32]);
@@ -1059,7 +1074,7 @@ mod tests {
let group = rekey_group(scope, &ROOT, &community_id, epoch).expect("derives"); let group = rekey_group(scope, &ROOT, &community_id, epoch).expect("derives");
let prior_commit = epoch_key_commitment(Epoch(0), &PRIOR_KEY); let prior_commit = epoch_key_commitment(Epoch(0), &PRIOR_KEY);
let chunks = build_rekey_chunks( let chunks = smol::block_on(build_rekey_chunks(
&rotator, &rotator,
&group, &group,
scope, scope,
@@ -1070,7 +1085,7 @@ mod tests {
None, None,
false, false,
AT, AT,
) ))
.expect("builds"); .expect("builds");
assert_eq!(chunks.len(), 1); assert_eq!(chunks.len(), 1);
@@ -1129,14 +1144,14 @@ mod tests {
.next() .next()
.expect("located"); .expect("located");
assert_eq!( assert_eq!(
open_blob( smol::block_on(open_blob(
&me, &me,
&rotator.public_key(), &rotator.public_key(),
scope, scope,
epoch, epoch,
located, located,
&community_id &community_id
) ))
.expect("opens") .expect("opens")
.new_key, .new_key,
[0xAA; 32] [0xAA; 32]
@@ -1269,26 +1284,26 @@ mod tests {
content: String, content: String,
at_secs: u64, at_secs: u64,
) -> Event { ) -> Event {
writer smol::block_on(writer.publish(
.publish( owner,
owner, Edition {
Edition { subkind,
subkind, entity,
entity, content: &content,
content: &content, head: None,
head: None, citation: None,
citation: None, },
}, at_secs,
at_secs, ))
) .expect("publishes")
.expect("publishes") .0
.0
} }
#[test] #[test]
fn a_rotation_needs_the_permission_and_must_strictly_outrank_every_target() { fn a_rotation_needs_the_permission_and_must_strictly_outrank_every_target() {
let owner = Keys::generate(); let owner = Keys::generate();
let minted = genesis(&owner, &CommunityMetadata::default(), AT).expect("mints"); let minted =
smol::block_on(genesis(&owner, &CommunityMetadata::default(), AT)).expect("mints");
let community_id = minted.identity.community_id; let community_id = minted.identity.community_id;
let read = let read =
control_group_key(&minted.community_root, &community_id, ROOT_EPOCH).expect("derives"); control_group_key(&minted.community_root, &community_id, ROOT_EPOCH).expect("derives");
@@ -1413,7 +1428,7 @@ mod tests {
.map(|_| { .map(|_| {
let member = Keys::generate(); let member = Keys::generate();
build_blob( smol::block_on(build_blob(
&rotator, &rotator,
&member.public_key(), &member.public_key(),
scope, scope,
@@ -1421,12 +1436,12 @@ mod tests {
&[0xCD; 32], &[0xCD; 32],
None, None,
None, None,
) ))
.expect("builds") .expect("builds")
}) })
.collect(); .collect();
let chunks = build_rekey_chunks( let chunks = smol::block_on(build_rekey_chunks(
&rotator, &rotator,
&group, &group,
scope, scope,
@@ -1437,7 +1452,7 @@ mod tests {
None, None,
false, false,
AT, AT,
) ))
.expect("builds"); .expect("builds");
assert_eq!(chunks.len(), 1, "a full send chunk is one event"); assert_eq!(chunks.len(), 1, "a full send chunk is one event");
@@ -1452,7 +1467,7 @@ mod tests {
wrapped: "x".to_owned(), wrapped: "x".to_owned(),
}); });
let chunks = build_rekey_chunks( let chunks = smol::block_on(build_rekey_chunks(
&rotator, &rotator,
&group, &group,
scope, scope,
@@ -1463,7 +1478,7 @@ mod tests {
None, None,
false, false,
AT, AT,
) ))
.expect("builds"); .expect("builds");
assert_eq!(chunks.len(), 2, "one over the cap splits across two events"); assert_eq!(chunks.len(), 2, "one over the cap splits across two events");
@@ -1485,8 +1500,13 @@ mod tests {
content: "{}", content: "{}",
at_secs: AT, at_secs: AT,
}); });
let seal = let seal = smol::block_on(cord01::build_seal(
cord01::build_seal(&rumor, SealForm::Plaintext, &prior_read, &owner).expect("seals"); &rumor,
SealForm::Plaintext,
&prior_read,
&owner,
))
.expect("seals");
let refounding = plan_refounding(Epoch(1)).expect("plans"); let refounding = plan_refounding(Epoch(1)).expect("plans");
let read = refounding.read(&community_id).expect("derives"); let read = refounding.read(&community_id).expect("derives");
@@ -1507,8 +1527,13 @@ mod tests {
assert_eq!(reopened.author, owner.public_key()); assert_eq!(reopened.author, owner.public_key());
// Only a plaintext seal can be carried forward. // Only a plaintext seal can be carried forward.
let encrypted = let encrypted = smol::block_on(cord01::build_seal(
cord01::build_seal(&rumor, SealForm::Encrypted, &prior_read, &owner).expect("seals"); &rumor,
SealForm::Encrypted,
&prior_read,
&owner,
))
.expect("seals");
assert!(matches!( assert!(matches!(
compact(&[encrypted], &read, &signer, AT + 1), compact(&[encrypted], &read, &signer, AT + 1),
Err(RekeyError::Stream(StreamError::NotRewrappable)) Err(RekeyError::Stream(StreamError::NotRewrappable))
@@ -1527,7 +1552,8 @@ mod tests {
}; };
let rumor = dissolved_tombstone_rumor(owner.public_key(), &community_id, AT); let rumor = dissolved_tombstone_rumor(owner.public_key(), &community_id, AT);
let wrap = seal_dissolved(&rumor, &community_id, &owner, AT).expect("seals"); let wrap =
smol::block_on(seal_dissolved(&rumor, &community_id, &owner, AT)).expect("seals");
assert!(verify_dissolved(&wrap, &identity)); assert!(verify_dissolved(&wrap, &identity));
assert_eq!( assert_eq!(
@@ -1538,18 +1564,18 @@ mod tests {
// Anyone holding the community id finds the address, but only the committed // Anyone holding the community id finds the address, but only the committed
// owner's signature counts. // owner's signature counts.
let impostor = Keys::generate(); let impostor = Keys::generate();
let forged = seal_dissolved( let forged = smol::block_on(seal_dissolved(
&dissolved_tombstone_rumor(impostor.public_key(), &community_id, AT), &dissolved_tombstone_rumor(impostor.public_key(), &community_id, AT),
&community_id, &community_id,
&impostor, &impostor,
AT, AT,
) ))
.expect("seals"); .expect("seals");
assert!(!verify_dissolved(&forged, &identity)); assert!(!verify_dissolved(&forged, &identity));
// The spec's all-zero `eid` is refused: it would let one owner's genuine // The spec's all-zero `eid` is refused: it would let one owner's genuine
// tombstone be re-wrapped at another of their communities and kill it. // tombstone be re-wrapped at another of their communities and kill it.
let zeroed = seal_dissolved( let zeroed = smol::block_on(seal_dissolved(
&cord01::build_rumor_secs( &cord01::build_rumor_secs(
KIND_CONTROL, KIND_CONTROL,
owner.public_key(), owner.public_key(),
@@ -1563,7 +1589,7 @@ mod tests {
&community_id, &community_id,
&owner, &owner,
AT, AT,
) ))
.expect("seals"); .expect("seals");
assert!(matches!( assert!(matches!(
open_dissolved(&zeroed, &community_id), open_dissolved(&zeroed, &community_id),
+1 -2
View File
@@ -1,5 +1,4 @@
//! One module per CORD document. CORD-07 (audio/video) is unimplemented, and mod rumor;
//! CORD-08's timer rides the Chat and Control planes it edits rather than owning a file.
pub mod cord01; pub mod cord01;
pub mod cord02; pub mod cord02;
+96
View File
@@ -0,0 +1,96 @@
use std::fmt;
use nostr_sdk::prelude::*;
use crate::cord01::StreamError;
use crate::cord04::{AuthorityCitation, TAG_CITATION, citation_from};
use crate::decode_hex_32;
#[derive(Debug)]
pub enum RumorError {
Stream(StreamError),
NotEncryptedSealed,
UnknownKind(u16),
MissingTag(&'static str),
DuplicateTag(&'static str),
BadTag(&'static str),
/// Neither a delete nor a timer notice may be erased by the policy it carries.
ExemptExpiration,
}
impl fmt::Display for RumorError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RumorError::Stream(error) => write!(f, "stream: {error}"),
RumorError::NotEncryptedSealed => write!(f, "rumor must ride an encrypted seal"),
RumorError::UnknownKind(kind) => write!(f, "not a rumor kind: {kind}"),
RumorError::MissingTag(name) => write!(f, "missing tag: {name}"),
RumorError::DuplicateTag(name) => write!(f, "duplicate tag: {name}"),
RumorError::BadTag(name) => write!(f, "malformed tag: {name}"),
RumorError::ExemptExpiration => {
write!(f, "a delete or timer notice must not carry an expiration")
}
}
}
}
impl std::error::Error for RumorError {}
impl From<StreamError> for RumorError {
fn from(error: StreamError) -> Self {
RumorError::Stream(error)
}
}
pub fn tag<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<Option<&'a [String]>, RumorError> {
let mut found: Option<&[String]> = None;
for candidate in rumor.tags.iter() {
let fields = candidate.as_slice();
if fields.first().map(String::as_str) != Some(name) {
continue;
}
if found.is_some() {
return Err(RumorError::DuplicateTag(name));
}
found = Some(fields);
}
Ok(found)
}
pub fn required<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<&'a [String], RumorError> {
tag(rumor, name)?.ok_or(RumorError::MissingTag(name))
}
pub fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, RumorError> {
fields
.get(1)
.map(String::as_str)
.ok_or(RumorError::BadTag(name))
}
pub fn pubkey(hex: &str, name: &'static str) -> Result<PublicKey, RumorError> {
let bytes = decode_hex_32(hex).map_err(|_| RumorError::BadTag(name))?;
PublicKey::from_slice(&bytes).map_err(|_| RumorError::BadTag(name))
}
pub fn optional_citation(rumor: &UnsignedEvent) -> Result<Option<AuthorityCitation>, RumorError> {
let Some(fields) = tag(rumor, TAG_CITATION)? else {
return Ok(None);
};
citation_from(fields)
.map(Some)
.ok_or(RumorError::BadTag(TAG_CITATION))
}
+272 -162
View File
@@ -3,17 +3,20 @@ use std::collections::{BTreeMap, BTreeSet};
use std::sync::LazyLock; use std::sync::LazyLock;
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use data_encoding::HEXLOWER;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::cord01::{KIND_WRAP_EPHEMERAL, OpenedStream}; use crate::cord01::{KIND_WRAP_EPHEMERAL, OpenedStream};
use crate::cord02::list::{CommunityListEntry, JoinMaterial};
use crate::cord02::{ use crate::cord02::{
ChannelMetadata, CommunityGenesis, CommunityMetadata, ControlFold, ROOT_EPOCH, ChannelMetadata, CommunityGenesis, CommunityMetadata, ControlFold, ROOT_EPOCH,
}; };
use crate::cord03::{self, ChatRumor, plane_keys}; use crate::cord03::{self, ChatRumor, plane_keys};
use crate::cord04::{EntityHead, Floors, ParsedEdition, vsk}; use crate::cord04::{EntityHead, Floors, ParsedEdition, vsk};
use crate::cord05::ChannelGrant;
use crate::derive::control_signer_group_key; use crate::derive::control_signer_group_key;
use crate::{ChannelId, CommunityId, Epoch, GroupKey}; use crate::{ChannelId, CommunityId, Epoch, Extra, GroupKey, decode_hex_32};
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate); static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
@@ -23,14 +26,17 @@ const MARK_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_T;
const MARK_VALUE: &str = "concord"; const MARK_VALUE: &str = "concord";
const WRAP_TAG: &str = "e"; const WRAP_TAG: &str = "e";
const KIND_TAG: &str = "k"; const KIND_TAG: &str = "k";
const STATE_PREFIX: &str = "concord/"; /// The `concord/` namespace for locally-keyed documents.
pub const STATE_PREFIX: &str = "concord/";
/// An already-expired rumor is refused at ingest. Returns whether it was kept. /// An already-expired rumor is refused at ingest. Returns whether it was kept.
pub async fn cache_rumor( pub async fn cache_rumor(
database: &dyn NostrDatabase, client: &Client,
channel: &ChannelId, channel: &ChannelId,
opened: &OpenedStream, opened: &OpenedStream,
) -> Result<bool> { ) -> Result<bool> {
let at = Timestamp::from_secs(opened.at_ms / 1000);
if cord03::expiration_of(&opened.rumor)? if cord03::expiration_of(&opened.rumor)?
.is_some_and(|expiration| expiration <= Timestamp::now()) .is_some_and(|expiration| expiration <= Timestamp::now())
{ {
@@ -45,23 +51,19 @@ pub async fn cache_rumor(
Tag::custom(CHANNEL_TAG.as_str(), [channel.to_hex()]), Tag::custom(CHANNEL_TAG.as_str(), [channel.to_hex()]),
Tag::public_key(opened.author), Tag::public_key(opened.author),
]; ];
let at = Timestamp::from_secs(opened.at_ms / 1000);
let event = EventBuilder::new(Kind::ApplicationSpecificData, opened.rumor.as_json()) let event = EventBuilder::new(Kind::ApplicationSpecificData, opened.rumor.as_json())
.tags(tags) .tags(tags)
.custom_created_at(at) .custom_created_at(at)
.finalize_async(&*LOCAL_KEYS) .finalize_async(&*LOCAL_KEYS)
.await?; .await?;
database.save_event(&event).await?; client.database().save_event(&event).await?;
Ok(true) Ok(true)
} }
pub async fn purge_expired( pub async fn purge_expired(client: &Client, channel: &ChannelId, now: Timestamp) -> Result<usize> {
database: &dyn NostrDatabase,
channel: &ChannelId,
now: Timestamp,
) -> Result<usize> {
let filter = Filter::new() let filter = Filter::new()
.kind(Kind::ApplicationSpecificData) .kind(Kind::ApplicationSpecificData)
.custom_tag(MARK_TAG, MARK_VALUE) .custom_tag(MARK_TAG, MARK_VALUE)
@@ -69,7 +71,7 @@ pub async fn purge_expired(
let mut expired = Vec::new(); let mut expired = Vec::new();
for event in database.query(filter).await? { for event in client.database().query(filter).await? {
let Ok(rumor) = UnsignedEvent::from_json(&event.content) else { let Ok(rumor) = UnsignedEvent::from_json(&event.content) else {
continue; continue;
}; };
@@ -86,14 +88,14 @@ pub async fn purge_expired(
let purged = expired.len(); let purged = expired.len();
if purged > 0 { if purged > 0 {
database.delete(Filter::new().ids(expired)).await?; client.database().delete(Filter::new().ids(expired)).await?;
} }
Ok(purged) Ok(purged)
} }
pub async fn query_rumors( pub async fn query_rumors(
database: &dyn NostrDatabase, client: &Client,
channel: &ChannelId, channel: &ChannelId,
until: Option<Timestamp>, until: Option<Timestamp>,
limit: usize, limit: usize,
@@ -108,7 +110,7 @@ pub async fn query_rumors(
} }
let mut newest: BTreeMap<String, Event> = BTreeMap::new(); let mut newest: BTreeMap<String, Event> = BTreeMap::new();
for event in database.query(filter).await? { for event in client.database().query(filter).await? {
let Some(rumor_id) = event.tags.identifier() else { let Some(rumor_id) = event.tags.identifier() else {
continue; continue;
}; };
@@ -141,12 +143,19 @@ pub struct ChannelKeyRef {
pub name: String, pub name: String,
pub private: bool, pub private: bool,
pub epoch: Epoch, pub epoch: Epoch,
/// The channel's read secret when the member was granted it.
///
/// A public channel derives its key from the `community_root` and carries none.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<[u8; 32]>,
} }
/// One local document per community, keyed by `concord/<community_id>`. /// One local document per community, keyed by `concord/<community_id>`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CommunityState { pub struct CommunityState {
pub id: CommunityId, pub id: CommunityId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub owner: PublicKey, pub owner: PublicKey,
pub owner_salt: [u8; 32], pub owner_salt: [u8; 32],
pub community_root: [u8; 32], pub community_root: [u8; 32],
@@ -176,6 +185,7 @@ impl CommunityState {
let mut channels = Vec::new(); let mut channels = Vec::new();
let mut heads = Vec::with_capacity(editions.len()); let mut heads = Vec::with_capacity(editions.len());
let mut relays = Vec::new(); let mut relays = Vec::new();
let mut name = None;
for edition in editions { for edition in editions {
heads.push(EntityHead { heads.push(EntityHead {
@@ -194,6 +204,7 @@ impl CommunityState {
.iter() .iter()
.filter_map(|relay| RelayUrl::parse(relay).ok()), .filter_map(|relay| RelayUrl::parse(relay).ok()),
); );
name = label(&metadata.name);
} }
vsk::CHANNEL_METADATA => { vsk::CHANNEL_METADATA => {
let metadata: ChannelMetadata = serde_json::from_str(&edition.content)?; let metadata: ChannelMetadata = serde_json::from_str(&edition.content)?;
@@ -202,6 +213,7 @@ impl CommunityState {
name: metadata.name, name: metadata.name,
private: metadata.private, private: metadata.private,
epoch: ROOT_EPOCH, epoch: ROOT_EPOCH,
key: None,
}); });
} }
_ => {} _ => {}
@@ -220,6 +232,7 @@ impl CommunityState {
Ok(Self { Ok(Self {
id: genesis.identity.community_id, id: genesis.identity.community_id,
name,
owner: genesis.identity.owner, owner: genesis.identity.owner,
owner_salt: genesis.identity.owner_salt, owner_salt: genesis.identity.owner_salt,
community_root: genesis.community_root, community_root: genesis.community_root,
@@ -235,6 +248,53 @@ impl CommunityState {
}) })
} }
pub fn from_join_material(material: &JoinMaterial, added_at_ms: u64) -> Result<Self> {
let control_pks = match material.control_pk {
Some(address) => BTreeMap::from([(material.root_epoch.0, address)]),
None => BTreeMap::new(),
};
let mut channels = Vec::with_capacity(material.channels.len());
for grant in &material.channels {
let key = match &grant.key {
Some(key) => Some(decode_hex_32(key)?),
None => None,
};
channels.push(ChannelKeyRef {
id: grant.id,
name: grant.name.clone(),
private: key.is_some(),
epoch: grant.epoch,
key,
});
}
Ok(Self {
id: material.community_id,
name: label(&material.name),
owner: material.owner,
owner_salt: decode_hex_32(&material.owner_salt)?,
community_root: decode_hex_32(&material.community_root)?,
root_epoch: material.root_epoch,
control_root: match &material.control_root {
Some(root) => Some(decode_hex_32(root)?),
None => None,
},
control_pks,
channels,
relays: material
.relays
.iter()
.filter_map(|relay| RelayUrl::parse(relay).ok())
.collect(),
heads: Vec::new(),
banned: BTreeSet::new(),
dissolved: false,
added_at_ms,
})
}
pub fn identifier(&self) -> String { pub fn identifier(&self) -> String {
state_identifier(&self.id) state_identifier(&self.id)
} }
@@ -256,6 +316,10 @@ impl CommunityState {
.iter() .iter()
.filter_map(|relay| RelayUrl::parse(relay).ok()) .filter_map(|relay| RelayUrl::parse(relay).ok())
.collect(); .collect();
if let Some(name) = label(&community.name) {
self.name = Some(name);
}
} }
for (id, metadata) in &fold.channels { for (id, metadata) in &fold.channels {
@@ -277,6 +341,7 @@ impl CommunityState {
name: metadata.name.clone(), name: metadata.name.clone(),
private: false, private: false,
epoch: self.root_epoch, epoch: self.root_epoch,
key: None,
}), }),
None => {} None => {}
} }
@@ -284,42 +349,110 @@ impl CommunityState {
} }
} }
pub fn list_entry(state: &CommunityState, name: &str) -> CommunityListEntry {
let material = JoinMaterial {
community_id: state.id,
owner: state.owner,
owner_salt: HEXLOWER.encode(&state.owner_salt),
community_root: HEXLOWER.encode(&state.community_root),
root_epoch: state.root_epoch,
control_pk: state.control_pks.get(&state.root_epoch.0).copied(),
control_root: state.control_root.map(|root| HEXLOWER.encode(&root)),
channels: state
.channels
.iter()
.map(|channel| ChannelGrant {
id: channel.id,
key: channel.key.map(|key| HEXLOWER.encode(&key)),
epoch: channel.epoch,
name: channel.name.clone(),
extra: Extra::default(),
})
.collect(),
relays: state.relays.iter().map(RelayUrl::to_string).collect(),
name: name.to_owned(),
extra: Extra::default(),
};
CommunityListEntry {
community_id: state.id,
seed: material.clone(),
current: material,
added_at: state.added_at_ms,
extra: Extra::default(),
}
}
fn label(name: &str) -> Option<String> {
let trimmed = name.trim();
(!trimmed.is_empty()).then(|| trimmed.to_owned())
}
fn state_identifier(id: &CommunityId) -> String { fn state_identifier(id: &CommunityId) -> String {
format!("{STATE_PREFIX}{}", id.to_hex()) format!("{STATE_PREFIX}{}", id.to_hex())
} }
pub async fn save_state<D>(database: &D, state: &CommunityState) -> Result<()> pub async fn save_state(client: &Client, state: &CommunityState) -> Result<()> {
where
D: NostrDatabase,
{
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?) let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
.tags([Tag::identifier(state.identifier())]) .tags([Tag::identifier(state.identifier())])
.finalize_async(&*LOCAL_KEYS) .finalize_async(&*LOCAL_KEYS)
.await?; .await?;
database.save_event(&event).await?; client.database().save_event(&event).await?;
Ok(()) Ok(())
} }
pub async fn load_state<D>(database: &D, id: &CommunityId) -> Result<Option<CommunityState>> pub async fn load_state(client: &Client, id: &CommunityId) -> Result<Option<CommunityState>> {
where
D: NostrDatabase,
{
let filter = Filter::new() let filter = Filter::new()
.kind(Kind::ApplicationSpecificData) .kind(Kind::ApplicationSpecificData)
.identifier(state_identifier(id)) .identifier(state_identifier(id))
.limit(1); .limit(1);
match database.query(filter).await?.into_iter().next() { match client.database().query(filter).await?.into_iter().next() {
Some(event) => Ok(Some(serde_json::from_str(&event.content)?)), Some(event) => Ok(Some(serde_json::from_str(&event.content)?)),
None => Ok(None), None => Ok(None),
} }
} }
/// The newest state document per community carried in the local database.
pub async fn load_states(client: &Client) -> Result<Vec<CommunityState>> {
let filter = Filter::new().kind(Kind::ApplicationSpecificData);
let mut newest: BTreeMap<CommunityId, Event> = 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::<CommunityState>(&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<CommunityId> {
let identifier = event.tags.identifier()?;
let hex = identifier.strip_prefix(STATE_PREFIX)?;
hex.parse().ok()
}
pub async fn backfill( pub async fn backfill(
client: &Client, client: &Client,
database: &dyn NostrDatabase,
channel: &ChannelId, channel: &ChannelId,
held: &[(Epoch, [u8; 32])], held: &[(Epoch, [u8; 32])],
until: Option<Timestamp>, until: Option<Timestamp>,
@@ -342,7 +475,7 @@ pub async fn backfill(
let (fresh, next) = advance(&page, &planes, channel, cursor, limit, &mut seen); let (fresh, next) = advance(&page, &planes, channel, cursor, limit, &mut seen);
for (opened, rumor) in fresh { for (opened, rumor) in fresh {
if cache_rumor(database, channel, &opened).await? { if cache_rumor(client, channel, &opened).await? {
found.push(rumor); found.push(rumor);
} }
} }
@@ -416,15 +549,11 @@ async fn fetch_page(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use nostr_memory::MemoryDatabase;
use super::*; use super::*;
use crate::Epoch;
use crate::cord01::{
KIND_WRAP, SealForm, build_rumor_ms, build_seal, channel_binding_tags, open_wrap, wrap_seal,
};
use crate::cord03::{build_message, seal_rumor}; use crate::cord03::{build_message, seal_rumor};
use crate::cord05::ChannelGrant;
use crate::derive::channel_group_key; use crate::derive::channel_group_key;
use crate::{Epoch, Extra};
const SECRET: [u8; 32] = [0x07u8; 32]; const SECRET: [u8; 32] = [0x07u8; 32];
const NEXT_SECRET: [u8; 32] = [0x11u8; 32]; const NEXT_SECRET: [u8; 32] = [0x11u8; 32];
@@ -472,7 +601,11 @@ mod tests {
at_ms, at_ms,
None, None,
); );
relay.insert(seal_rumor(&rumor, &group, &author, false).expect("seals").0); relay.insert(
smol::block_on(seal_rumor(&rumor, &group, &author, false))
.expect("seals")
.0,
);
} }
let mut seen = BTreeSet::new(); let mut seen = BTreeSet::new();
@@ -501,148 +634,125 @@ mod tests {
} }
#[test] #[test]
fn rumors_read_back_after_a_restart() { fn from_join_material_materializes_a_subscribable_state_with_or_without_the_control_root() {
let database = MemoryDatabase::unbounded(); let owner = Keys::generate().public_key();
let channel = ChannelId::from_bytes([0xabu8; 32]); let control_pk = Keys::generate().public_key();
let author = Keys::generate(); let staff = ChannelId::from_bytes([0x9c; 32]);
let general = ChannelId::from_bytes([0x9d; 32]);
smol::block_on(async { let material = JoinMaterial {
let group = channel_group_key(&SECRET, &channel, Epoch(0)).expect("derives"); community_id: CommunityId::from_bytes([0x42; 32]),
owner,
owner_salt: "01".repeat(32),
community_root: "02".repeat(32),
root_epoch: Epoch(3),
control_pk: Some(control_pk),
control_root: Some("03".repeat(32)),
channels: vec![
ChannelGrant {
id: staff,
key: Some("04".repeat(32)),
epoch: Epoch(2),
name: "staff".to_owned(),
extra: Extra::default(),
},
ChannelGrant {
id: general,
key: None,
epoch: Epoch(0),
name: "general".to_owned(),
extra: Extra::default(),
},
],
relays: vec!["wss://relay.example".to_owned()],
name: "Room".to_owned(),
extra: Extra::default(),
};
for (content, at_ms) in [("first", 1_000_000u64), ("second", 2_000_000)] { let state = CommunityState::from_join_material(&material, 7).expect("materializes");
let rumor = build_rumor_ms(
9,
author.public_key(),
content,
channel_binding_tags(&channel, Epoch(0)),
at_ms,
);
let seal = build_seal(&rumor, SealForm::Encrypted, &group, &author).expect("seals");
let (wrap, _) = wrap_seal(
&seal,
&group,
KIND_WRAP,
Timestamp::from_secs(at_ms / 1000),
&[],
)
.expect("wraps");
let opened = open_wrap(&wrap, &group).expect("opens"); assert_eq!(state.id, material.community_id);
cache_rumor(&database, &channel, &opened) assert_eq!(state.owner, owner);
.await assert_eq!(state.owner_salt, [0x01; 32]);
.expect("caches"); assert_eq!(state.community_root, [0x02; 32]);
} assert_eq!(state.root_epoch, Epoch(3));
assert_eq!(state.control_root, Some([0x03; 32]));
assert_eq!(state.control_pks, BTreeMap::from([(3, control_pk)]));
assert!(
state.heads.is_empty(),
"the first control fold fills the heads"
);
assert!(state.banned.is_empty());
assert!(!state.dissolved);
assert_eq!(state.relays.len(), 1);
assert_eq!(state.added_at_ms, 7);
// The group key is gone; only the local cache stands in for it. // A granted key lands on the channel and makes it private; a grant with
let rumors = query_rumors(&database, &channel, None, 10) // no key is a public channel.
.await let granted = state
.expect("queries"); .channels
assert_eq!(rumors.len(), 2, "both messages come back"); .iter()
assert_eq!(rumors[0].content, "second", "newest first"); .find(|c| c.id == staff)
assert_eq!(rumors[1].content, "first"); .expect("staff");
assert!(granted.private);
assert_eq!(granted.key, Some([0x04; 32]));
assert_eq!(granted.epoch, Epoch(2));
assert_eq!(granted.name, "staff");
// A page boundary in message time, not in cache time. let public = state
let until = Timestamp::from_secs(1_500); .channels
let page = query_rumors(&database, &channel, Some(until), 10) .iter()
.await .find(|c| c.id == general)
.expect("queries"); .expect("general");
assert_eq!(page.len(), 1); assert!(!public.private);
assert_eq!(page[0].content, "first"); assert_eq!(public.key, None);
let capped = query_rumors(&database, &channel, None, 1) // A member who is not staff carries no control_root, but reading needs no
.await // secret: the address rides in the material either way.
.expect("queries"); let mut member = material.clone();
assert_eq!(capped.len(), 1); member.control_root = None;
assert_eq!(capped[0].content, "second"); let state = CommunityState::from_join_material(&member, 7).expect("materializes");
}); assert_eq!(state.control_root, None);
assert_eq!(state.control_pks, BTreeMap::from([(3, control_pk)]));
} }
#[test] #[test]
fn an_expired_rumor_is_refused_at_ingest_and_purged_by_the_sweep() { fn load_states_reads_one_document_per_community_and_ignores_other_documents() {
let database = MemoryDatabase::unbounded();
let channel = ChannelId::from_bytes([0x77u8; 32]);
let author = Keys::generate();
let group = channel_group_key(&SECRET, &channel, Epoch(0)).expect("derives");
let now = Timestamp::now().as_secs();
smol::block_on(async { smol::block_on(async {
// A live timer is stored; one that already elapsed is refused at ingest. let client = ClientBuilder::default()
assert!( .database(nostr_memory::MemoryDatabase::unbounded())
cache( .build();
&database,
&group,
&channel,
&author,
"live",
Some(3_600),
now
)
.await
);
assert!(
!cache(
&database,
&group,
&channel,
&author,
"gone",
Some(1),
now - 120
)
.await
);
let stored = query_rumors(&database, &channel, None, 10) let state = CommunityState {
.await id: CommunityId::from_bytes([0x42; 32]),
.expect("queries"); name: Some("Anime and Manga".to_owned()),
assert_eq!(stored.len(), 1); owner: Keys::generate().public_key(),
assert_eq!(stored[0].content, "live"); 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(),
dissolved: false,
added_at_ms: 7,
};
// Hiding is not disappearing: the sweep removes the row itself, save_state(&client, &state).await.expect("saves");
// judged on the rumor's own signed tag.
let purged = purge_expired(&database, &channel, Timestamp::from_secs(now + 7_200))
.await
.expect("sweeps");
assert_eq!(purged, 1);
assert!(
query_rumors(&database, &channel, None, 10)
.await
.expect("queries")
.is_empty()
);
// An untimed rumor is never swept, whatever the clock says. // A cached rumor is also an application-specific document, but not a
assert!(cache(&database, &group, &channel, &author, "timeless", None, now).await); // state document, so the prefix keeps it out of the state scan.
let purged = purge_expired(&database, &channel, Timestamp::from_secs(now + 86_400)) let other = EventBuilder::new(Kind::ApplicationSpecificData, "{}")
.await .tags([Tag::identifier("deadbeef")])
.expect("sweeps"); .finalize(&*LOCAL_KEYS)
assert_eq!(purged, 0); .expect("builds");
client.database().save_event(&other).await.expect("saves");
let loaded = load_states(&client).await.expect("loads");
assert_eq!(loaded, vec![state]);
}); });
} }
async fn cache(
database: &MemoryDatabase,
group: &GroupKey,
channel: &ChannelId,
author: &Keys,
content: &str,
timer: Option<u64>,
at_secs: u64,
) -> bool {
let rumor = build_message(
author.public_key(),
channel,
Epoch(0),
content,
None,
at_secs * 1_000,
timer,
);
let (wrap, _) = seal_rumor(&rumor, group, author, false).expect("seals");
let opened = open_wrap(&wrap, group).expect("opens");
cache_rumor(database, channel, &opened)
.await
.expect("caches")
}
} }
+34
View File
@@ -0,0 +1,34 @@
use anyhow::{Result, anyhow};
use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig};
use base64::{Engine as _, alphabet};
/// Unpadded base64url (RFC 4648 §5), 43 characters for 32 bytes: §8's value
/// encoding at any depth.
///
/// The reader tolerates non-zero trailing bits; the writer never emits them.
/// The spec's own worked example (`examples.md` §6.2) contains five such
/// values, and a reader cannot tell a mis-encoded named field from a correctly
/// encoded one, so the boundary is the writer's alone.
const BASE64URL: GeneralPurpose = GeneralPurpose::new(
&alphabet::URL_SAFE,
GeneralPurposeConfig::new()
.with_encode_padding(false)
.with_decode_padding_mode(DecodePaddingMode::RequireNone)
.with_decode_allow_trailing_bits(true),
);
pub(crate) fn encode(bytes: &[u8]) -> String {
BASE64URL.encode(bytes)
}
/// Decodes one 32-byte value, the width every §8 field has.
pub(crate) fn decode_32(value: &str) -> Result<[u8; 32]> {
let bytes = BASE64URL
.decode(value.trim())
.map_err(|error| anyhow!("invalid base64url: {error}"))?;
bytes
.as_slice()
.try_into()
.map_err(|_| anyhow!("expected 32 bytes, got {}", bytes.len()))
}
+35
View File
@@ -1,9 +1,13 @@
pub mod base64url;
pub mod derive; pub mod derive;
use anyhow::{Result, anyhow, bail}; use anyhow::{Result, anyhow, bail};
use data_encoding::HEXLOWER; use data_encoding::HEXLOWER;
use rand::TryRng as _; use rand::TryRng as _;
use rand::rngs::SysRng; use rand::rngs::SysRng;
use serde::Serialize;
use crate::Extra;
/// Uppercase and other non-canonical spellings are rejected. /// Uppercase and other non-canonical spellings are rejected.
pub(crate) fn decode_hex_32(value: &str) -> Result<[u8; 32]> { pub(crate) fn decode_hex_32(value: &str) -> Result<[u8; 32]> {
@@ -38,3 +42,34 @@ pub(crate) fn random_32() -> Result<[u8; 32]> {
fill_random(&mut bytes)?; fill_random(&mut bytes)?;
Ok(bytes) Ok(bytes)
} }
/// Hex to unpadded base64url for one 32-byte §8 value.
pub(crate) fn hex32_to_base64(value: &str) -> Result<String> {
Ok(base64url::encode(&decode_hex_32(value)?))
}
/// Unpadded base64url to lowercase hex for one 32-byte §8 value.
pub(crate) fn base64_to_hex32(value: &str) -> Result<String> {
Ok(HEXLOWER.encode(&base64url::decode_32(value)?))
}
/// Canonical JSON bytes: the total-order tie-break every content merge uses.
pub(crate) fn canonical<T: Serialize>(value: &T) -> String {
serde_json::to_string(value).unwrap_or_default()
}
/// Unions an unknown-field map. Where both sides carry a key, the
/// lexicographically lowest canonical bytes win, so two devices converge
/// instead of flapping.
pub(crate) fn union(into: &mut Extra, other: Extra) {
for (key, value) in other {
let replace = match into.get(&key) {
Some(existing) => canonical(&value) < canonical(existing),
None => true,
};
if replace {
into.insert(key, value);
}
}
}
+31 -13
View File
@@ -24,8 +24,8 @@ use ui::{Disableable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
const IDENTIFIER: &str = "coop:device"; const IDENTIFIER: &str = "coop:device";
pub fn init(window: &mut Window, cx: &mut App) { pub fn init(cx: &mut App) {
DeviceRegistry::set_global(cx.new(|cx| DeviceRegistry::new(window, cx)), cx); DeviceRegistry::set_global(cx.new(DeviceRegistry::new), cx);
} }
struct GlobalDeviceRegistry(Entity<DeviceRegistry>); struct GlobalDeviceRegistry(Entity<DeviceRegistry>);
@@ -89,7 +89,8 @@ impl DeviceRegistry {
} }
/// Create a new device registry instance /// Create a new device registry instance
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { fn new(cx: &mut Context<Self>) -> Self {
let entity = cx.entity().downgrade();
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let settings = AppSettings::global(cx); let settings = AppSettings::global(cx);
@@ -114,8 +115,10 @@ impl DeviceRegistry {
}), }),
); );
cx.defer_in(window, |this, window, cx| { cx.defer(move |cx| {
this.handle_notifications(window, cx); entity
.update(cx, |this, cx| this.handle_notifications(cx))
.ok();
}); });
Self { Self {
@@ -127,7 +130,7 @@ impl DeviceRegistry {
} }
} }
fn handle_notifications(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn handle_notifications(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer(); let signer = nostr.read(cx).signer();
@@ -168,18 +171,18 @@ impl DeviceRegistry {
Ok(()) Ok(())
})); }));
self.tasks.push(cx.spawn_in(window, async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
while let Ok(event) = rx.recv_async().await { while let Ok(event) = rx.recv_async().await {
match event.kind { match event.kind {
Kind::Custom(10044) => { Kind::Custom(10044) => {
this.update_in(cx, |this, _window, cx| { this.update(cx, |this, cx| {
this.set_encryption(&event, cx); this.set_encryption(&event, cx);
})?; })?;
} }
// New request event from other device // New request event from other device
Kind::Custom(4454) => { Kind::Custom(4454) => {
this.update_in(cx, |this, window, cx| { this.update(cx, |this, cx| {
this.ask_for_approval(event, window, cx); this.ask_for_approval(event, cx);
})?; })?;
} }
// New response event from the master device // New response event from the master device
@@ -591,7 +594,7 @@ impl DeviceRegistry {
} }
/// Handle encryption request /// Handle encryption request
fn ask_for_approval(&mut self, event: Event, window: &mut Window, cx: &mut Context<Self>) { fn ask_for_approval(&mut self, event: Event, cx: &mut Context<Self>) {
// Ignore if there is already a pending request // Ignore if there is already a pending request
if self.pending_request { if self.pending_request {
return; return;
@@ -600,7 +603,18 @@ impl DeviceRegistry {
// Show notification // Show notification
let notification = self.notification(event, cx); let notification = self.notification(event, cx);
window.push_notification(notification, cx);
// The registry is global and not bound to a window, so surface the
// request in an open window.
if let Some(window) = cx.windows().first().copied() {
if let Err(error) = window.update(cx, |_view, window, cx| {
window.push_notification(notification, cx);
}) {
log::warn!("Failed to show encryption key request: {error}");
}
} else {
log::warn!("Failed to show encryption key request: no open window");
}
} }
/// Build a notification for the encryption request. /// Build a notification for the encryption request.
@@ -655,7 +669,11 @@ impl DeviceRegistry {
.child( .child(
h_flex() h_flex()
.gap_2() .gap_2()
.child(Avatar::new(profile.avatar()).xsmall()) .child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.xsmall(),
)
.child(profile.name()), .child(profile.name()),
), ),
), ),
+7 -6
View File
@@ -3,7 +3,7 @@ use std::sync::RwLock;
use anyhow::{Error, anyhow}; use anyhow::{Error, anyhow};
use common::EventExt; use common::EventExt;
use gpui::{App, AppContext, Context, Entity, Global, Task, Window}; use gpui::{App, AppContext, Context, Entity, Global, Task};
use instant::Duration; use instant::Duration;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
@@ -13,8 +13,8 @@ mod person;
pub use person::*; pub use person::*;
pub fn init(window: &mut Window, cx: &mut App) { pub fn init(cx: &mut App) {
PersonRegistry::set_global(cx.new(|cx| PersonRegistry::new(window, cx)), cx); PersonRegistry::set_global(cx.new(PersonRegistry::new), cx);
} }
struct GlobalPersonRegistry(Entity<PersonRegistry>); struct GlobalPersonRegistry(Entity<PersonRegistry>);
@@ -56,7 +56,8 @@ impl PersonRegistry {
} }
/// Create a new person registry instance /// Create a new person registry instance
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { fn new(cx: &mut Context<Self>) -> Self {
let entity = cx.entity().downgrade();
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
@@ -96,8 +97,8 @@ impl PersonRegistry {
})); }));
// Load all user profiles from the database // Load all user profiles from the database
cx.defer_in(window, |this, _window, cx| { cx.defer(move |cx| {
this.load(cx); entity.update(cx, |this, cx| this.load(cx)).ok();
}); });
Self { Self {
+8 -4
View File
@@ -103,14 +103,18 @@ impl Person {
self.messaging_relays.first().cloned() self.messaging_relays.first().cloned()
} }
/// Get profile avatar /// Get profile picture, if the profile has one
pub fn avatar(&self) -> SharedString { pub fn avatar(&self) -> Option<SharedString> {
self.metadata() self.metadata()
.picture .picture
.as_ref() .as_ref()
.filter(|picture| !picture.is_empty()) .filter(|picture| !picture.is_empty())
.map(|picture| picture.into()) .map(SharedString::from)
.unwrap_or_else(|| "brand/avatar.png".into()) }
/// A stable seed for this profile's generated avatar
pub fn avatar_seed(&self) -> SharedString {
SharedString::from(self.public_key().to_hex())
} }
/// Get profile name /// Get profile name
+36 -15
View File
@@ -8,8 +8,8 @@ use serde::{Deserialize, Serialize};
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use theme::{Theme, ThemeFamily, ThemeMode}; use theme::{Theme, ThemeFamily, ThemeMode};
pub fn init(window: &mut Window, cx: &mut App) { pub fn init(cx: &mut App) {
AppSettings::set_global(cx.new(|cx| AppSettings::new(window, cx)), cx) AppSettings::set_global(cx.new(AppSettings::new), cx)
} }
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/"; const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
@@ -46,6 +46,8 @@ setting_accessors! {
pub nip4e: bool, pub nip4e: bool,
pub trusted_relays: Vec<String>, pub trusted_relays: Vec<String>,
pub file_server: Url, pub file_server: Url,
pub pinned_rooms: Vec<u64>,
pub expanded_sections: Option<Vec<String>>,
} }
/// Signer kind /// Signer kind
@@ -130,6 +132,14 @@ pub struct Settings {
/// Server for blossom media attachments /// Server for blossom media attachments
pub file_server: Url, pub file_server: Url,
/// Pinned sidebar room ids, in pin order
#[serde(default)]
pub pinned_rooms: Vec<u64>,
/// Expanded sidebar tree sections; `None` means the default sections
#[serde(default)]
pub expanded_sections: Option<Vec<String>>,
} }
impl Default for Settings { impl Default for Settings {
@@ -142,6 +152,8 @@ impl Default for Settings {
nip4e: false, nip4e: false,
trusted_relays: vec![], trusted_relays: vec![],
file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(), file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(),
pinned_rooms: vec![],
expanded_sections: None,
} }
} }
} }
@@ -171,12 +183,20 @@ impl AppSettings {
cx.global::<GlobalAppSettings>().0.clone() cx.global::<GlobalAppSettings>().0.clone()
} }
/// The underlying settings entity, which notifies whenever any field changes.
/// Settings load asynchronously, so observers can watch it to pick up values
/// that arrive after construction.
pub fn entity(&self) -> &Entity<Settings> {
&self.inner
}
/// Set the global settings instance /// Set the global settings instance
fn set_global(state: Entity<Self>, cx: &mut App) { fn set_global(state: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalAppSettings(state)); cx.set_global(GlobalAppSettings(state));
} }
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { fn new(cx: &mut Context<Self>) -> Self {
let entity = cx.entity().downgrade();
let inner = cx.new(|_| Settings::default()); let inner = cx.new(|_| Settings::default());
let mut subscriptions = smallvec![]; let mut subscriptions = smallvec![];
@@ -188,8 +208,8 @@ impl AppSettings {
); );
// Run at the end of current cycle // Run at the end of current cycle
cx.defer_in(window, |this, window, cx| { cx.defer(move |cx| {
this.load(window, cx); entity.update(cx, |this, cx| this.load(cx)).ok();
}); });
Self { Self {
@@ -207,7 +227,7 @@ impl AppSettings {
} }
/// Load settings /// Load settings
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn load(&mut self, cx: &mut Context<Self>) {
let task: Task<Result<Settings, Error>> = cx.background_spawn(async move { let task: Task<Result<Settings, Error>> = cx.background_spawn(async move {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
{ {
@@ -219,7 +239,7 @@ impl AppSettings {
Err(anyhow!("Not found")) Err(anyhow!("Not found"))
}); });
cx.spawn_in(window, async move |this, cx| { cx.spawn(async move |this, cx| {
let mut settings = task.await.unwrap_or(Settings::default()); let mut settings = task.await.unwrap_or(Settings::default());
// Move settings still pointed at the old default file server over to the new one // Move settings still pointed at the old default file server over to the new one
@@ -228,9 +248,10 @@ impl AppSettings {
} }
// Update settings // Update settings
this.update_in(cx, |this, window, cx| { this.update(cx, |this, cx| {
this.set_settings(settings, cx); this.set_settings(settings, cx);
this.apply_theme(window, cx); this.apply_theme(None, cx);
cx.refresh_windows();
}) })
.ok(); .ok();
}) })
@@ -262,7 +283,7 @@ impl AppSettings {
}); });
// Apply the new theme // Apply the new theme
self.apply_theme(window, cx); self.apply_theme(Some(window), cx);
} }
/// Reset theme /// Reset theme
@@ -271,22 +292,22 @@ impl AppSettings {
this.theme = None; this.theme = None;
cx.notify(); cx.notify();
}); });
self.apply_theme(window, cx); self.apply_theme(Some(window), cx);
} }
/// Apply theme /// Apply theme
pub fn apply_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) { pub fn apply_theme(&mut self, mut window: Option<&mut Window>, cx: &mut Context<Self>) {
if let Some(name) = self.inner.read(cx).theme.as_ref() { if let Some(name) = self.inner.read(cx).theme.as_ref() {
let mode = self.inner.read(cx).theme_mode; let mode = self.inner.read(cx).theme_mode;
if let Ok(new_theme) = ThemeFamily::from_assets(name) { if let Ok(new_theme) = ThemeFamily::from_assets(name) {
Theme::apply_theme(Rc::new(new_theme), Some(window), cx); Theme::apply_theme(Rc::new(new_theme), window.as_deref_mut(), cx);
Theme::change(mode, Some(window), cx); Theme::change(mode, window, cx);
} else { } else {
log::info!("Failed to load theme: {name}"); log::info!("Failed to load theme: {name}");
} }
} else { } else {
Theme::apply_theme(Rc::new(ThemeFamily::default()), Some(window), cx); Theme::apply_theme(Rc::new(ThemeFamily::default()), window, cx);
} }
} }
+57
View File
@@ -318,6 +318,63 @@ pub async fn download_and_decrypt_to_file(
Err(anyhow!("File download not supported on web")) Err(anyhow!("File download not supported on web"))
} }
/// The cache file a decrypted blob for `plaintext_sha256` is written to.
#[cfg(not(target_arch = "wasm32"))]
fn blob_cache_path(plaintext_sha256: &str) -> PathBuf {
std::env::temp_dir()
.join("coop-blobs")
.join(plaintext_sha256)
}
/// Download an encrypted blob whose pointer carries the *plaintext* hash
/// and write the decrypted bytes to a content-addressed cache file,
/// so later renders skip the network.
///
/// The cache file carries no extension: `img` sniffs the format from the bytes.
#[cfg(not(target_arch = "wasm32"))]
pub async fn download_and_decrypt_to_cache(
url: &Url,
key: &str,
nonce: &str,
plaintext_sha256: &str,
cx: &AsyncApp,
) -> Result<PathBuf, Error> {
let path = blob_cache_path(plaintext_sha256);
if smol::fs::metadata(&path).await.is_ok() {
return Ok(path);
}
let data = download_and_decrypt(url, key, nonce, None, cx).await?;
if !sha256_hex(&data).eq_ignore_ascii_case(plaintext_sha256) {
bail!("Blob hash mismatch");
}
let Some(parent) = path.parent() else {
bail!("Invalid blob cache path");
};
smol::fs::create_dir_all(parent).await?;
// Write under a temporary name first, so an interrupted download is never reused
let partial = path.with_extension("download");
smol::fs::write(&partial, data).await?;
smol::fs::rename(&partial, &path).await?;
Ok(path)
}
#[cfg(target_arch = "wasm32")]
pub async fn download_and_decrypt_to_cache(
_url: &Url,
_key: &str,
_nonce: &str,
_plaintext_sha256: &str,
_cx: &AsyncApp,
) -> Result<PathBuf, Error> {
Err(anyhow!("Blob download not supported on web"))
}
fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> { fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
tags.iter() tags.iter()
.find(|tag| tag.kind() == name) .find(|tag| tag.kind() == name)
+21 -16
View File
@@ -4,7 +4,7 @@ use anyhow::{Error, anyhow};
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use browser_signer_proxy::prelude::*; use browser_signer_proxy::prelude::*;
use common::config_dir; use common::config_dir;
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, Window}; use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task};
use gpui_tokio::Tokio; use gpui_tokio::Tokio;
use instant::Duration; use instant::Duration;
use nostr_connect::prelude::*; use nostr_connect::prelude::*;
@@ -29,7 +29,7 @@ pub use nip4e::*;
pub use nip05::*; pub use nip05::*;
pub use signer::{CoopAuthUrlHandler, UniversalSigner}; pub use signer::{CoopAuthUrlHandler, UniversalSigner};
pub fn init(window: &mut Window, cx: &mut App, cli_key: Option<SecretKey>) { pub fn init(cx: &mut App, cli_key: Option<SecretKey>) {
// rustls uses the `aws_lc_rs` provider by default // rustls uses the `aws_lc_rs` provider by default
// This only errors if the default provider has already // This only errors if the default provider has already
// been installed. We can ignore this `Result`. // been installed. We can ignore this `Result`.
@@ -42,7 +42,7 @@ pub fn init(window: &mut Window, cx: &mut App, cli_key: Option<SecretKey>) {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
gpui_tokio::init(cx); gpui_tokio::init(cx);
NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(window, cx, cli_key)), cx); NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(cx, cli_key)), cx);
} }
struct GlobalNostrRegistry(Entity<NostrRegistry>); struct GlobalNostrRegistry(Entity<NostrRegistry>);
@@ -105,7 +105,8 @@ impl NostrRegistry {
} }
/// Create a new nostr instance /// Create a new nostr instance
fn new(window: &mut Window, cx: &mut Context<Self>, cli_key: Option<SecretKey>) -> Self { fn new(cx: &mut Context<Self>, cli_key: Option<SecretKey>) -> Self {
let entity = cx.entity().downgrade();
let signer = UniversalSigner::new(Keys::generate()); let signer = UniversalSigner::new(Keys::generate());
let authenticator = SignerAuthenticator::new(signer.clone()); let authenticator = SignerAuthenticator::new(signer.clone());
@@ -132,19 +133,23 @@ impl NostrRegistry {
}) })
.build(); .build();
// Connect to bootstrap relays after the window is ready // Connect to bootstrap relays once the registry has been returned to the app
cx.defer_in(window, |this, _window, cx| { cx.defer(move |cx| {
this.connect_bootstrap_relays(cx); entity
.update(cx, |this, cx| {
this.connect_bootstrap_relays(cx);
if cfg!(target_arch = "wasm32") { if cfg!(target_arch = "wasm32") {
cx.emit(StateEvent::NoSigner); cx.emit(StateEvent::NoSigner);
} else if let Some(secret) = cli_key { } else if let Some(secret) = cli_key {
// Use CLI-provided key -- same path as get_user_credential // Use CLI-provided key -- same path as get_user_credential
let keys = Keys::new(secret); let keys = Keys::new(secret);
this.set_signer(keys, cx); this.set_signer(keys, cx);
} else { } else {
this.get_user_credential(cx); this.get_user_credential(cx);
} }
})
.ok();
}); });
Self { Self {
+473 -29
View File
@@ -1,12 +1,25 @@
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
AbsoluteLength, App, Div, Hsla, ImageSource, Img, InteractiveElement, Interactivity, AbsoluteLength, AnyElement, App, Bounds, Div, Hsla, ImageSource, InteractiveElement,
IntoElement, ObjectFit, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage, Interactivity, IntoElement, ObjectFit, ParentElement, PathBuilder, Pixels, Point, RenderOnce,
Window, div, img, px, SharedString, StyleRefinement, Styled, StyledImage, Window, canvas, div, img, point, px,
}; };
use theme::ActiveTheme; use theme::ActiveTheme;
use crate::{Selectable, Sizable, Size}; use crate::{Selectable, Sizable, Size, StyledExt};
/// Number of rows and columns in the generated pixel grid.
const PIXEL_GRID: usize = 8;
/// Probability that a cell in the left half of the grid is filled.
const FILL_PROBABILITY: f32 = 0.42;
/// Probability that a filled cell uses the accent shade instead of the main color.
const ACCENT_PROBABILITY: f32 = 0.25;
/// Minimum number of filled left-half cells, so a pattern never reads as empty.
const MIN_FILLED: usize = 5;
/// Fallback seed for an avatar that has neither a picture nor a seed of its own.
const FALLBACK_SEED: &str = "coop";
/// Number of segments used to approximate the avatar circle.
const CIRCLE_SEGMENTS: usize = 32;
/// Returns the size of the avatar based on the given [`Size`]. /// Returns the size of the avatar based on the given [`Size`].
pub(super) fn avatar_size(size: Size) -> AbsoluteLength { pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
@@ -19,19 +32,350 @@ pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
} }
} }
/// An element that renders a user avatar with customizable appearance options. /// A deterministic, offline pixel-art avatar derived from a seed.
///
/// Use it for entities that have no profile picture: the same seed always
/// renders the same pattern, so identities stay recognizable without a
/// network round trip. The pattern is painted as geometry and cropped to a
/// circle, at the same sizes as [`Avatar`].
/// ///
/// # Examples /// # Examples
/// ///
/// ``` /// ```
/// use ui::{Avatar}; /// use ui::avatar::PixelAvatar;
/// ///
/// Avatar::new("path/to/image.png").grayscale(true).border_color(gpui::red()); /// PixelAvatar::new("alice");
/// ```
#[derive(IntoElement)]
pub struct PixelAvatar {
seed: u64,
size: Size,
style: StyleRefinement,
}
impl PixelAvatar {
/// Creates a pixel avatar from `seed`.
pub fn new(seed: impl AsRef<str>) -> Self {
Self {
seed: fnv1a(seed.as_ref().as_bytes()),
size: Size::Medium,
style: StyleRefinement::default(),
}
}
}
impl Sizable for PixelAvatar {
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = size.into();
self
}
}
impl Styled for PixelAvatar {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for PixelAvatar {
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
let side = avatar_size(self.size).to_pixels(window.rem_size());
let seed = self.seed;
canvas(
move |_bounds, _window, _cx| seed,
move |bounds, seed, window, cx| {
let theme = cx.theme();
let main = Hsla {
h: (theme.icon_accent.h + seed as f32 / u64::MAX as f32) % 1.,
s: 0.6,
l: if theme.is_dark() { 0.6 } else { 0.45 },
a: 1.,
};
let shade = if theme.is_dark() {
Hsla {
l: (main.l * 1.6).min(0.95),
..main
}
} else {
Hsla {
l: (main.l * 0.45).max(0.18),
..main
}
};
let circle = circle_polygon(bounds.center(), bounds.size.width.as_f32() / 2.);
paint_polygons(window, std::iter::once(&circle), main.opacity(0.16));
let pattern = pixel_pattern(seed);
let mut cells = Vec::new();
for (value, color) in [(1u8, main), (2u8, shade)] {
cells.clear();
for row in 0..PIXEL_GRID {
for col in 0..PIXEL_GRID {
if pattern[row * PIXEL_GRID + col] != value {
continue;
}
let cell = clip_polygon(&cell_polygon(&bounds, row, col), &circle);
if cell.len() >= 3 {
cells.push(cell);
}
}
}
paint_polygons(window, cells.iter(), color);
}
},
)
.refine_style(&self.style)
.size(side)
.flex_shrink_0()
}
}
/// Builds the mirrored fill pattern for `seed`.
fn pixel_pattern(seed: u64) -> [u8; PIXEL_GRID * PIXEL_GRID] {
let mut rng = PixelRng::new(seed);
let mut pattern = [0u8; PIXEL_GRID * PIXEL_GRID];
let mut filled = 0usize;
for row in 0..PIXEL_GRID {
for col in 0..PIXEL_GRID / 2 {
if rng.chance(FILL_PROBABILITY) {
let accent = rng.chance(ACCENT_PROBABILITY);
set_cell(&mut pattern, row, col, if accent { 2 } else { 1 });
filled += 1;
}
}
}
if filled < MIN_FILLED {
let half = PIXEL_GRID * PIXEL_GRID / 2;
let start = (rng.next() % half as u64) as usize;
for offset in 0..half {
if filled >= MIN_FILLED {
break;
}
let ix = (start + offset) % half;
let row = ix / (PIXEL_GRID / 2);
let col = ix % (PIXEL_GRID / 2);
if pattern[row * PIXEL_GRID + col] == 0 {
set_cell(&mut pattern, row, col, 1);
filled += 1;
}
}
}
pattern
}
/// Paints `polygons` as a single anti-aliased filled path in `color`.
fn paint_polygons<'a>(
window: &mut Window,
polygons: impl IntoIterator<Item = &'a Vec<Point<Pixels>>>,
color: Hsla,
) {
let mut builder = PathBuilder::fill();
let mut painted = false;
for polygon in polygons {
if polygon.len() >= 3 {
builder.add_polygon(polygon, true);
painted = true;
}
}
if painted && let Ok(path) = builder.build() {
window.paint_path(path, color);
}
}
/// Approximates the circle of `radius` around `center` as a convex polygon,
/// wound so that its interior is on the left of every directed edge.
fn circle_polygon(center: Point<Pixels>, radius: f32) -> Vec<Point<Pixels>> {
let center_x = center.x.as_f32();
let center_y = center.y.as_f32();
(0..CIRCLE_SEGMENTS)
.map(|index| {
let angle = std::f32::consts::TAU * index as f32 / CIRCLE_SEGMENTS as f32;
point(
px(center_x + radius * angle.cos()),
px(center_y + radius * angle.sin()),
)
})
.collect()
}
/// The four corners of cell `(row, col)` of the grid laid out in `bounds`.
fn cell_polygon(bounds: &Bounds<Pixels>, row: usize, col: usize) -> [Point<Pixels>; 4] {
let cell = bounds.size.width.as_f32() / PIXEL_GRID as f32;
let left = bounds.origin.x.as_f32() + col as f32 * cell;
let top = bounds.origin.y.as_f32() + row as f32 * cell;
[
point(px(left), px(top)),
point(px(left + cell), px(top)),
point(px(left + cell), px(top + cell)),
point(px(left), px(top + cell)),
]
}
/// Clips `subject` to the convex `clip` polygon, keeping the part inside it.
fn clip_polygon(subject: &[Point<Pixels>], clip: &[Point<Pixels>]) -> Vec<Point<Pixels>> {
let mut current = subject.to_vec();
let mut next = Vec::with_capacity(subject.len() + 4);
for (&start, &end) in clip.iter().zip(clip.iter().cycle().skip(1)) {
if current.is_empty() {
break;
}
next.clear();
let mut previous = match current.last() {
Some(&vertex) => vertex,
None => break,
};
for &vertex in current.iter() {
let previous_inside = is_inside(start, end, previous);
let vertex_inside = is_inside(start, end, vertex);
if vertex_inside {
if !previous_inside
&& let Some(crossing) = line_intersection(start, end, previous, vertex)
{
next.push(crossing);
}
next.push(vertex);
} else if previous_inside
&& let Some(crossing) = line_intersection(start, end, previous, vertex)
{
next.push(crossing);
}
previous = vertex;
}
std::mem::swap(&mut current, &mut next);
}
current
}
/// Whether `vertex` lies on the interior side of the directed edge `start -> end`.
fn is_inside(start: Point<Pixels>, end: Point<Pixels>, vertex: Point<Pixels>) -> bool {
let start_x = start.x.as_f32();
let start_y = start.y.as_f32();
let edge_x = end.x.as_f32() - start_x;
let edge_y = end.y.as_f32() - start_y;
let to_vertex_x = vertex.x.as_f32() - start_x;
let to_vertex_y = vertex.y.as_f32() - start_y;
edge_x * to_vertex_y - edge_y * to_vertex_x >= 0.
}
/// The intersection of segment `from -> to` with the infinite line `start -> end`.
fn line_intersection(
start: Point<Pixels>,
end: Point<Pixels>,
from: Point<Pixels>,
to: Point<Pixels>,
) -> Option<Point<Pixels>> {
let start_x = start.x.as_f32();
let start_y = start.y.as_f32();
let edge_x = end.x.as_f32() - start_x;
let edge_y = end.y.as_f32() - start_y;
let from_x = from.x.as_f32();
let from_y = from.y.as_f32();
let segment_x = to.x.as_f32() - from_x;
let segment_y = to.y.as_f32() - from_y;
let denominator = edge_x * segment_y - edge_y * segment_x;
if denominator.abs() < f32::EPSILON {
return None;
}
let offset_x = from_x - start_x;
let offset_y = from_y - start_y;
let t = (edge_y * offset_x - edge_x * offset_y) / denominator;
Some(point(
px(from_x + segment_x * t),
px(from_y + segment_y * t),
))
}
/// Fills `cell (row, col)` and its horizontal mirror.
fn set_cell(pattern: &mut [u8; PIXEL_GRID * PIXEL_GRID], row: usize, col: usize, value: u8) {
pattern[row * PIXEL_GRID + col] = value;
pattern[row * PIXEL_GRID + (PIXEL_GRID - 1 - col)] = value;
}
/// FNV-1a 64-bit hash, stable across platforms and runs.
fn fnv1a(bytes: &[u8]) -> u64 {
let mut hash = 0xcbf2_9ce4_8422_2325u64;
for &byte in bytes {
hash ^= byte as u64;
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
/// Tiny xorshift64* PRNG for deriving the pattern from the seed.
struct PixelRng(u64);
impl PixelRng {
fn new(seed: u64) -> Self {
Self(seed.max(1))
}
fn next(&mut self) -> u64 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
x.wrapping_mul(0x2545_f491_4f6c_dd1d)
}
fn chance(&mut self, probability: f32) -> bool {
self.next() as f32 / (u64::MAX as f32) < probability
}
}
/// Renders the generated pixel avatar shown in place of a missing picture.
fn generated_avatar(seed: Option<&str>, size: Pixels) -> AnyElement {
PixelAvatar::new(seed.unwrap_or(FALLBACK_SEED))
.with_size(size)
.into_any_element()
}
/// An element that renders a user avatar with customizable appearance options.
///
/// Entities without a picture still get a stable identity: the avatar falls
/// back to a [`PixelAvatar`] seeded through [`Avatar::seed`], both when there
/// is no picture and when the picture fails to load.
///
/// # Examples
///
/// ```
/// use ui::avatar::Avatar;
///
/// Avatar::new(None).seed("alice");
/// ``` /// ```
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct Avatar { pub struct Avatar {
base: Div, base: Div,
image: Img, picture: Option<ImageSource>,
grayscale: bool,
seed: Option<SharedString>,
style: StyleRefinement, style: StyleRefinement,
size: Size, size: Size,
border_color: Option<Hsla>, border_color: Option<Hsla>,
@@ -39,11 +383,25 @@ pub struct Avatar {
} }
impl Avatar { impl Avatar {
/// Creates a new avatar element with the specified image source. /// Creates an avatar for an entity whose profile picture may be missing.
pub fn new(src: impl Into<ImageSource>) -> Self { ///
/// Use [`Avatar::seed`] to choose the generated
/// pixel avatar rendered when `picture` is `None`.
pub fn new(picture: Option<SharedString>) -> Self {
Self::from_picture(picture.map(ImageSource::from))
}
/// Creates an avatar from an already-resolved source.
pub fn from_source(picture: impl Into<ImageSource>) -> Self {
Self::from_picture(Some(picture.into()))
}
fn from_picture(picture: Option<ImageSource>) -> Self {
Avatar { Avatar {
base: div(), base: div(),
image: img(src), picture,
grayscale: false,
seed: None,
style: StyleRefinement::default(), style: StyleRefinement::default(),
size: Size::Medium, size: Size::Medium,
border_color: None, border_color: None,
@@ -51,17 +409,26 @@ impl Avatar {
} }
} }
/// Sets the seed for the generated pixel avatar.
///
/// The seed should be a stable identifier of the entity the avatar
/// represents, such as a public key.
pub fn seed(mut self, seed: impl Into<SharedString>) -> Self {
self.seed = Some(seed.into());
self
}
/// Applies a grayscale filter to the avatar image. /// Applies a grayscale filter to the avatar image.
/// ///
/// # Examples /// # Examples
/// ///
/// ``` /// ```
/// use ui::{Avatar, AvatarShape}; /// use ui::avatar::Avatar;
/// ///
/// let avatar = Avatar::new("path/to/image.png").grayscale(true); /// Avatar::new(None).grayscale(true);
/// ``` /// ```
pub fn grayscale(mut self, grayscale: bool) -> Self { pub fn grayscale(mut self, grayscale: bool) -> Self {
self.image = self.image.grayscale(grayscale); self.grayscale = grayscale;
self self
} }
@@ -113,8 +480,24 @@ impl RenderOnce for Avatar {
} else { } else {
px(0.) px(0.)
}; };
let image_size = avatar_size(self.size); let image_size = avatar_size(self.size).to_pixels(window.rem_size());
let container_size = image_size.to_pixels(window.rem_size()) + border_width * 2.; let container_size = image_size + border_width * 2.;
let content = match self.picture {
Some(picture) => {
let seed = self.seed;
let grayscale = self.grayscale;
img(picture)
.size(image_size)
.rounded_full()
.object_fit(ObjectFit::Cover)
.grayscale(grayscale)
.bg(cx.theme().ghost_element_background)
.with_fallback(move || generated_avatar(seed.as_deref(), image_size))
.into_any_element()
}
None => generated_avatar(self.seed.as_deref(), image_size),
};
div() div()
.flex_shrink_0() .flex_shrink_0()
@@ -124,18 +507,79 @@ impl RenderOnce for Avatar {
.when_some(self.border_color, |this, color| { .when_some(self.border_color, |this, color| {
this.border(border_width).border_color(color) this.border(border_width).border_color(color)
}) })
.child( .child(content)
self.image }
.size(image_size) }
.rounded_full()
.object_fit(ObjectFit::Cover) #[cfg(test)]
.bg(cx.theme().ghost_element_background) mod tests {
.with_fallback(move || { use super::*;
img("brand/avatar.png")
.size(image_size) #[test]
.rounded_full() fn pixel_patterns_are_symmetric_and_stable() {
.into_any_element() for seed in 0..50 {
}), let pattern = pixel_pattern(seed);
) let filled = pattern.iter().filter(|&&cell| cell != 0).count();
assert!(
filled >= MIN_FILLED * 2,
"pattern too sparse for seed {seed}"
);
for row in 0..PIXEL_GRID {
for col in 0..PIXEL_GRID {
assert_eq!(
pattern[row * PIXEL_GRID + col],
pattern[row * PIXEL_GRID + (PIXEL_GRID - 1 - col)],
"asymmetric pattern for seed {seed} at ({row}, {col})"
);
}
}
}
for seed in [0, 1, 42, u64::MAX] {
assert_eq!(pixel_pattern(seed), pixel_pattern(seed));
}
assert_ne!(pixel_pattern(42), pixel_pattern(43));
}
fn area(polygon: &[Point<Pixels>]) -> f32 {
let mut sum: f32 = 0.;
for (&a, &b) in polygon.iter().zip(polygon.iter().cycle().skip(1)) {
sum += a.x.as_f32() * b.y.as_f32() - b.x.as_f32() * a.y.as_f32();
}
(sum / 2.).abs()
}
#[test]
fn clipping_keeps_only_the_part_inside_the_circle() {
let circle = circle_polygon(point(px(10.), px(10.)), 10.);
let square = |left: f32, top: f32| {
[
point(px(left), px(top)),
point(px(left + 4.), px(top)),
point(px(left + 4.), px(top + 4.)),
point(px(left), px(top + 4.)),
]
};
let inside = clip_polygon(&square(8., 8.), &circle);
assert!((area(&inside) - 16.).abs() < 0.05, "area {}", area(&inside));
assert!(clip_polygon(&square(20., 20.), &circle).is_empty());
let straddling = clip_polygon(&square(0., 0.), &circle);
for vertex in &straddling {
let delta_x = vertex.x.as_f32() - 10.;
let delta_y = vertex.y.as_f32() - 10.;
assert!(
delta_x.hypot(delta_y) <= 10. + 0.1,
"clipped vertex outside the circle"
);
}
let area = area(&straddling);
assert!(area > 0. && area < 16., "area {area}");
} }
} }
+124 -59
View File
@@ -24,6 +24,7 @@ use crate::menu::DropdownMenu as _;
use crate::resizable::{resize_handle, resize_handle_appearance}; use crate::resizable::{resize_handle, resize_handle_appearance};
use crate::tab::Tab; use crate::tab::Tab;
use crate::tab::tab_bar::TabBar; use crate::tab::tab_bar::TabBar;
use crate::title_bar::{title_bar_drag_handlers, window_controls};
use crate::{IconName, Selectable, Sizable, StyledExt, h_flex, v_flex}; use crate::{IconName, Selectable, Sizable, StyledExt, h_flex, v_flex};
mod panel; mod panel;
@@ -31,12 +32,34 @@ pub use panel::*;
actions!(dock, [ToggleZoom, ClosePanel]); actions!(dock, [ToggleZoom, ClosePanel]);
pub type TitleBarRenderer = fn(&mut Window, &mut App) -> AnyElement;
#[derive(Default)]
pub struct TitleBarChrome {
trailing: Cell<Option<TitleBarRenderer>>,
}
impl TitleBarChrome {
pub fn set_trailing(&self, renderer: TitleBarRenderer) {
self.trailing.set(Some(renderer));
}
fn trailing(&self, window: &mut Window, cx: &mut App) -> Option<AnyElement> {
self.trailing.get().map(|render| render(window, cx))
}
}
pub fn dock_area( pub fn dock_area(
id: impl Into<SharedString>, id: impl Into<SharedString>,
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) -> Entity<DockArea> { ) -> (Entity<DockArea>, Rc<TitleBarChrome>) {
let shared = Rc::new(SkinShared::default()); let chrome = Rc::new(TitleBarChrome::default());
let shared = Rc::new(SkinShared {
area: RefCell::new(None),
resizing: Cell::new(None),
chrome: chrome.clone(),
});
let area = cx.new(|cx| { let area = cx.new(|cx| {
DockArea::new(id, None, window, cx).with_renderer(Rc::new(DockSkin { DockArea::new(id, None, window, cx).with_renderer(Rc::new(DockSkin {
shared: shared.clone(), shared: shared.clone(),
@@ -44,7 +67,7 @@ pub fn dock_area(
}); });
*shared.area.borrow_mut() = Some(area.downgrade()); *shared.area.borrow_mut() = Some(area.downgrade());
area (area, chrome)
} }
pub fn add_panel( pub fn add_panel(
@@ -167,6 +190,7 @@ fn right_top_group(node: &PaneNode) -> Option<NodeId> {
struct SkinShared { struct SkinShared {
area: RefCell<Option<WeakEntity<DockArea>>>, area: RefCell<Option<WeakEntity<DockArea>>>,
resizing: Cell<Option<DockPlacement>>, resizing: Cell<Option<DockPlacement>>,
chrome: Rc<TitleBarChrome>,
} }
impl SkinShared { impl SkinShared {
@@ -394,6 +418,17 @@ impl TabGroupSkin {
} }
} }
fn is_title_bar_group(&self, group: &TabGroupContext, cx: &App) -> bool {
let Some(area) = self.shared.area() else {
return false;
};
area.read(cx)
.layout(DockPlacement::Center)
.and_then(|tree| left_top_group(tree.root()))
== Some(group.node())
}
fn render_toolbar( fn render_toolbar(
&self, &self,
group: &TabGroupContext, group: &TabGroupContext,
@@ -473,16 +508,17 @@ impl TabGroupSkin {
let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx); let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
let has_leading = left_button.is_some() || bottom_button.is_some(); let has_leading = left_button.is_some() || bottom_button.is_some();
let drag = tab_drag(group, ix, cx); let drag = tab_drag(group, ix, cx);
let is_title_bar = self.is_title_bar_group(group, cx);
let trailing_chrome = is_title_bar
.then(|| self.shared.chrome.trailing(window, cx))
.flatten();
h_flex() let bar = h_flex()
.id("tab-title-bar")
.justify_between() .justify_between()
.items_center() .items_center()
.line_height(rems(1.0)) .line_height(rems(1.0))
.h(TABBAR_HEIGHT) .h(TABBAR_HEIGHT)
.py_2()
.pl_3()
.pr_2()
.rounded_t(cx.theme().radius_lg)
.bg(cx.theme().panel_background) .bg(cx.theme().panel_background)
.when(left_button.is_some(), |this| this.pl_2()) .when(left_button.is_some(), |this| this.pl_2())
.when(right_button.is_some(), |this| this.pr_2()) .when(right_button.is_some(), |this| this.pr_2())
@@ -499,9 +535,9 @@ impl TabGroupSkin {
.child( .child(
div() div()
.id("tab") .id("tab")
.flex_1() .flex_initial()
.min_w_0()
.px_2() .px_2()
.min_w_16()
.overflow_hidden() .overflow_hidden()
.whitespace_nowrap() .whitespace_nowrap()
.child( .child(
@@ -524,6 +560,14 @@ impl TabGroupSkin {
}) })
}), }),
) )
.child({
let space = div().id("tab-title-space").flex_1().h_full();
if is_title_bar {
title_bar_drag_handlers(space, window, cx).into_any_element()
} else {
space.into_any_element()
}
})
.child( .child(
h_flex() h_flex()
.flex_shrink_0() .flex_shrink_0()
@@ -532,7 +576,18 @@ impl TabGroupSkin {
.child(self.render_toolbar(group, window, cx)) .child(self.render_toolbar(group, window, cx))
.children(right_button), .children(right_button),
) )
.into_any_element() .when_some(trailing_chrome, |this, chrome| this.child(chrome));
if is_title_bar {
h_flex()
.h(TABBAR_HEIGHT)
.bg(cx.theme().panel_background)
.child(bar.flex_1())
.child(window_controls())
.into_any_element()
} else {
bar.into_any_element()
}
} }
fn render_tabs( fn render_tabs(
@@ -556,13 +611,36 @@ impl TabGroupSkin {
.iter() .iter()
.position(|panel| panel.panel_id(cx) == displayed) .position(|panel| panel.panel_id(cx) == displayed)
}); });
let is_title_bar = self.is_title_bar_group(group, cx);
let trailing_chrome = is_title_bar
.then(|| self.shared.chrome.trailing(window, cx))
.flatten();
let empty_space = div()
.id("tab-bar-empty-space")
.h_full()
.flex_grow_1()
.min_w_16()
.when(droppable, |this| {
this.drag_over::<DragPanel>(|this, _, _, cx| this.bg(cx.theme().surface_background))
.on_drop({
let group = TabGroupContext::clone(group);
move |drag: &DragPanel, window, cx| {
let ix = (drag.source() == group.node()).then(|| tabs_count - 1);
group.drop_panel(drag.clone(), ix, false, window, cx);
}
})
});
let empty_space = if is_title_bar {
title_bar_drag_handlers(empty_space, window, cx).into_any_element()
} else {
empty_space.into_any_element()
};
TabBar::new("tab-bar") let bar = TabBar::new("tab-bar")
.track_scroll(&self.scroll_handle) .track_scroll(&self.scroll_handle)
.h(TABBAR_HEIGHT) .h(TABBAR_HEIGHT)
.bg(cx.theme().panel_background) .bg(cx.theme().panel_background)
.rounded_t(cx.theme().radius_lg) .when(is_title_bar || has_leading, |this| {
.when(has_leading, |this| {
this.prefix( this.prefix(
h_flex() h_flex()
.items_center() .items_center()
@@ -639,26 +717,7 @@ impl TabGroupSkin {
}) })
}) })
})) }))
.last_empty_space( .last_empty_space(empty_space)
// Empty space so a panel can be moved past the last tab.
div()
.id("tab-bar-empty-space")
.h_full()
.flex_grow_1()
.min_w_16()
.when(droppable, |this| {
this.drag_over::<DragPanel>(|this, _, _, cx| {
this.bg(cx.theme().surface_background)
})
.on_drop({
let group = TabGroupContext::clone(group);
move |drag: &DragPanel, window, cx| {
let ix = (drag.source() == group.node()).then(|| tabs_count - 1);
group.drop_panel(drag.clone(), ix, false, window, cx);
}
})
}),
)
.when(!collapsed, |this| { .when(!collapsed, |this| {
this.suffix( this.suffix(
h_flex() h_flex()
@@ -669,10 +728,22 @@ impl TabGroupSkin {
.px_0p5() .px_0p5()
.gap_1() .gap_1()
.child(self.render_toolbar(group, window, cx)) .child(self.render_toolbar(group, window, cx))
.children(right_button), .children(right_button)
.children(trailing_chrome),
) )
}) });
.into_any_element()
if is_title_bar {
h_flex()
.h(TABBAR_HEIGHT)
.w_full()
.bg(cx.theme().panel_background)
.child(bar.flex_1())
.child(window_controls())
.into_any_element()
} else {
bar.into_any_element()
}
} }
fn dock_toggle_button( fn dock_toggle_button(
@@ -738,28 +809,23 @@ impl TabGroupSkin {
} }
impl TabGroupRenderer for TabGroupSkin { impl TabGroupRenderer for TabGroupSkin {
fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> { fn frame(&self, group: &TabGroupContext, _: &mut Window, _cx: &mut App) -> Stateful<Div> {
div() div().id("tab-panel").when(!group.is_collapsed(), |this| {
.id("tab-panel") this.on_action({
.p_1() let group = TabGroupContext::clone(group);
.rounded(cx.theme().radius_lg) move |_: &ToggleZoom, window, cx| group.toggle_zoom(window, cx)
.when(cx.theme().shadow, |this| this.shadow_xs())
.when(!group.is_collapsed(), |this| {
this.on_action({
let group = TabGroupContext::clone(group);
move |_: &ToggleZoom, window, cx| group.toggle_zoom(window, cx)
})
.on_action({
let group = TabGroupContext::clone(group);
move |_: &ClosePanel, window, cx| {
let Some(panel) = group.active_panel() else {
return;
};
let panel = panel.panel_id(cx);
group.close(panel, window, cx);
}
})
}) })
.on_action({
let group = TabGroupContext::clone(group);
move |_: &ClosePanel, window, cx| {
let Some(panel) = group.active_panel() else {
return;
};
let panel = panel.panel_id(cx);
group.close(panel, window, cx);
}
})
})
} }
fn render_tab_bar( fn render_tab_bar(
@@ -811,7 +877,6 @@ impl TabGroupRenderer for TabGroupSkin {
.child( .child(
div() div()
.size_full() .size_full()
.rounded_b(cx.theme().radius_lg)
.bg(cx.theme().panel_background) .bg(cx.theme().panel_background)
.overflow_hidden() .overflow_hidden()
.child(panel.cached(StyleRefinement::default().v_flex().size_full())), .child(panel.cached(StyleRefinement::default().v_flex().size_full())),
+6
View File
@@ -33,12 +33,14 @@ pub enum IconName {
Close, Close,
CloseCircle, CloseCircle,
CloseCircleFill, CloseCircleFill,
Compass,
Copy, Copy,
Device, Device,
Door, Door,
Ellipsis, Ellipsis,
Emoji, Emoji,
Eye, Eye,
Folder,
Input, Input,
Info, Info,
Invite, Invite,
@@ -47,6 +49,7 @@ pub enum IconName {
Link, Link,
Loader, Loader,
Lock, Lock,
Message,
Moon, Moon,
Plus, Plus,
PlusCircle, PlusCircle,
@@ -106,12 +109,14 @@ impl IconNamed for IconName {
Self::Close => "icons/close.svg", Self::Close => "icons/close.svg",
Self::CloseCircle => "icons/close-circle.svg", Self::CloseCircle => "icons/close-circle.svg",
Self::CloseCircleFill => "icons/close-circle-fill.svg", Self::CloseCircleFill => "icons/close-circle-fill.svg",
Self::Compass => "icons/compass.svg",
Self::Copy => "icons/copy.svg", Self::Copy => "icons/copy.svg",
Self::Device => "icons/device.svg", Self::Device => "icons/device.svg",
Self::Door => "icons/door.svg", Self::Door => "icons/door.svg",
Self::Ellipsis => "icons/ellipsis.svg", Self::Ellipsis => "icons/ellipsis.svg",
Self::Emoji => "icons/emoji.svg", Self::Emoji => "icons/emoji.svg",
Self::Eye => "icons/eye.svg", Self::Eye => "icons/eye.svg",
Self::Folder => "icons/folder.svg",
Self::Input => "icons/input.svg", Self::Input => "icons/input.svg",
Self::Info => "icons/info.svg", Self::Info => "icons/info.svg",
Self::Invite => "icons/invite.svg", Self::Invite => "icons/invite.svg",
@@ -120,6 +125,7 @@ impl IconNamed for IconName {
Self::Link => "icons/link.svg", Self::Link => "icons/link.svg",
Self::Loader => "icons/loader.svg", Self::Loader => "icons/loader.svg",
Self::Lock => "icons/lock.svg", Self::Lock => "icons/lock.svg",
Self::Message => "icons/message.svg",
Self::Moon => "icons/moon.svg", Self::Moon => "icons/moon.svg",
Self::Plus => "icons/plus.svg", Self::Plus => "icons/plus.svg",
Self::PlusCircle => "icons/plus-circle.svg", Self::PlusCircle => "icons/plus-circle.svg",
+1
View File
@@ -18,6 +18,7 @@ pub mod indicator;
pub mod input; pub mod input;
pub mod menu; pub mod menu;
pub mod modal; pub mod modal;
pub mod nav_item;
pub mod notification; pub mod notification;
pub mod popover; pub mod popover;
pub mod resizable; pub mod resizable;
+100 -47
View File
@@ -1,15 +1,18 @@
use std::rc::Rc; use std::rc::Rc;
use gpui::{ use gpui::{
Anchor, Context, DismissEvent, ElementId, Entity, Focusable, InteractiveElement, IntoElement, Anchor, AnyElement, Context, DismissEvent, ElementId, Entity, Focusable, InteractiveElement,
RenderOnce, SharedString, StyleRefinement, Styled, Window, IntoElement, MouseButton, RenderOnce, SharedString, StyleRefinement, Styled, Window,
}; };
use crate::Selectable; use crate::Selectable;
use crate::avatar::Avatar; use crate::avatar::Avatar;
use crate::button::Button; use crate::button::Button;
use crate::menu::PopupMenu; use crate::menu::PopupMenu;
use crate::popover::Popover; use crate::popover::{Popover, PopoverState};
/// Builds the items of a popup menu on each render.
type MenuBuilder = dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu;
/// A dropdown menu trait for buttons and other interactive elements /// A dropdown menu trait for buttons and other interactive elements
pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement + 'static { pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement + 'static {
@@ -44,8 +47,7 @@ pub struct DropdownMenuPopover<T: Selectable + IntoElement + 'static> {
style: StyleRefinement, style: StyleRefinement,
anchor: Anchor, anchor: Anchor,
trigger: T, trigger: T,
#[allow(clippy::type_complexity)] builder: Rc<MenuBuilder>,
builder: Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>,
} }
impl<T> DropdownMenuPopover<T> impl<T> DropdownMenuPopover<T>
@@ -80,19 +82,95 @@ where
} }
} }
/// Opens a [`PopupMenu`] when its child is clicked with a mouse button
/// (right by default), keeping the child's own click handler intact.
#[derive(IntoElement)]
pub struct ContextMenu {
id: ElementId,
anchor: Anchor,
mouse_button: MouseButton,
child: AnyElement,
builder: Rc<MenuBuilder>,
}
impl ContextMenu {
pub fn new(
id: impl Into<ElementId>,
child: impl IntoElement,
builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
) -> Self {
Self {
id: id.into(),
anchor: Anchor::TopLeft,
mouse_button: MouseButton::Right,
child: child.into_any_element(),
builder: Rc::new(builder),
}
}
/// Set the anchor corner of the menu, default is `Anchor::TopLeft`.
pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
self.anchor = anchor.into();
self
}
/// Set the mouse button that opens the menu, default is `MouseButton::Right`.
pub fn mouse_button(mut self, mouse_button: MouseButton) -> Self {
self.mouse_button = mouse_button;
self
}
}
#[derive(Default)] #[derive(Default)]
struct DropdownMenuState { struct MenuState {
menu: Option<Entity<PopupMenu>>, menu: Option<Entity<PopupMenu>>,
} }
/// Builds the menu once and reuses it until it is dismissed.
///
/// The popover content closure runs on every render, so rebuilding the menu
/// entity each time would drop its focus and selection state.
fn cached_menu(
menu_state: &Entity<MenuState>,
builder: Rc<MenuBuilder>,
window: &mut Window,
cx: &mut Context<PopoverState>,
) -> Entity<PopupMenu> {
if let Some(menu) = menu_state.read(cx).menu.clone() {
return menu;
}
let menu = PopupMenu::build(window, cx, move |menu, window, cx| {
builder(menu, window, cx)
});
menu_state.update(cx, |state, _| {
state.menu = Some(menu.clone());
});
menu.focus_handle(cx).focus(window, cx);
let popover_state = cx.entity();
window
.subscribe(&menu, cx, {
let menu_state = menu_state.clone();
move |_, _: &DismissEvent, window, cx| {
popover_state.update(cx, |state, cx| state.dismiss(window, cx));
menu_state.update(cx, |state, _| {
state.menu = None;
});
}
})
.detach();
menu
}
impl<T> RenderOnce for DropdownMenuPopover<T> impl<T> RenderOnce for DropdownMenuPopover<T>
where where
T: Selectable + IntoElement + 'static, T: Selectable + IntoElement + 'static,
{ {
fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement { fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement {
let builder = self.builder.clone(); let builder = self.builder.clone();
let menu_state = let menu_state = window.use_keyed_state(self.id.clone(), cx, |_, _| MenuState::default());
window.use_keyed_state(self.id.clone(), cx, |_, _| DropdownMenuState::default());
Popover::new(SharedString::from(format!("popover:{}", self.id))) Popover::new(SharedString::from(format!("popover:{}", self.id)))
.appearance(false) .appearance(false)
@@ -100,46 +178,21 @@ where
.trigger(self.trigger) .trigger(self.trigger)
.trigger_style(self.style) .trigger_style(self.style)
.anchor(self.anchor) .anchor(self.anchor)
.content(move |_, window, cx| { .content(move |_, window, cx| cached_menu(&menu_state, builder.clone(), window, cx))
// Here is special logic to only create the PopupMenu once and reuse it. }
// Because this `content` will called in every time render, so we need to store the menu }
// in state to avoid recreating at every render.
//
// And we also need to rebuild the menu when it is dismissed, to rebuild menu items
// dynamically for support `dropdown_menu` method, so we listen for DismissEvent below.
let menu = match menu_state.read(cx).menu.clone() {
Some(menu) => menu,
None => {
let builder = builder.clone();
let menu = PopupMenu::build(window, cx, move |menu, window, cx| {
builder(menu, window, cx)
});
menu_state.update(cx, |state, _| {
state.menu = Some(menu.clone());
});
menu.focus_handle(cx).focus(window, cx);
// Listen for dismiss events from the PopupMenu to close the popover. impl RenderOnce for ContextMenu {
let popover_state = cx.entity(); fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement {
window let builder = self.builder.clone();
.subscribe(&menu, cx, { let menu_state = window.use_keyed_state(self.id.clone(), cx, |_, _| MenuState::default());
let menu_state = menu_state.clone();
move |_, _: &DismissEvent, window, cx| {
popover_state.update(cx, |state, cx| {
state.dismiss(window, cx);
});
menu_state.update(cx, |state, _| {
state.menu = None;
});
}
})
.detach();
menu.clone() Popover::new(SharedString::from(format!("context-menu:{}", self.id)))
} .appearance(false)
}; .overlay_closable(false)
.anchor(self.anchor)
menu.clone() .mouse_button(self.mouse_button)
}) .trigger_with(move |_open, _window, _cx| self.child)
.content(move |_, window, cx| cached_menu(&menu_state, builder.clone(), window, cx))
} }
} }
+1 -1
View File
@@ -4,7 +4,7 @@ mod dropdown_menu;
mod menu_item; mod menu_item;
mod popup_menu; mod popup_menu;
pub use dropdown_menu::DropdownMenu; pub use dropdown_menu::{ContextMenu, DropdownMenu};
pub use popup_menu::{PopupMenu, PopupMenuItem}; pub use popup_menu::{PopupMenu, PopupMenuItem};
pub(crate) fn init(cx: &mut App) { pub(crate) fn init(cx: &mut App) {
+100
View File
@@ -0,0 +1,100 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, ElementId, InteractiveElement, IntoElement, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Window,
div,
};
use theme::ActiveTheme;
use crate::{StyledExt, h_flex};
/// A single navigation entry in a sidebar.
///
/// It has an arbitrary leading element, such as an icon or avatar, and a text
/// label. It can carry an optional trailing suffix, such as a status icon, and
/// an optional click handler. Rows with a click handler are highlighted on
/// hover and show a pointer cursor.
#[allow(clippy::type_complexity)]
#[derive(IntoElement)]
pub struct NavItem {
id: ElementId,
style: StyleRefinement,
icon: AnyElement,
label: SharedString,
/// Trailing element at the right edge of the row, after the ellipsized label.
suffix: Option<AnyElement>,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
}
impl NavItem {
pub fn new(
id: impl Into<ElementId>,
label: impl Into<SharedString>,
icon: impl IntoElement,
) -> Self {
Self {
id: id.into(),
style: StyleRefinement::default(),
icon: icon.into_any_element(),
label: label.into(),
suffix: None,
on_click: None,
}
}
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
self.suffix = Some(suffix.into_any_element());
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Rc::new(handler));
self
}
}
impl Styled for NavItem {
fn style(&mut self) -> &mut StyleRefinement {
&mut self.style
}
}
impl RenderOnce for NavItem {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let clickable = self.on_click.is_some();
h_flex()
.id(self.id)
.refine_style(&self.style)
.px_2()
.py_1()
.w_full()
.gap_2()
.rounded(cx.theme().radius)
.text_color(cx.theme().text)
.child(self.icon)
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_sm()
.child(self.label),
)
.when_some(self.suffix, |this, suffix| {
this.child(div().flex_shrink_0().child(suffix))
})
.when(clickable, |this| {
this.cursor_pointer()
.hover(|this| this.bg(cx.theme().ghost_element_hover))
})
.when_some(self.on_click, |this, handler| {
this.on_click(move |event, window, cx| handler(event, window, cx))
})
}
}
+13
View File
@@ -87,6 +87,19 @@ impl Popover {
self self
} }
/// Set the trigger from a builder, for elements that have no selected state.
///
/// [`Self::trigger`] marks the trigger as selected while the popover is
/// open, so it cannot be used with elements whose selection carries a
/// different meaning, such as a row that indicates the current room.
pub fn trigger_with<F>(mut self, trigger: F) -> Self
where
F: FnOnce(bool, &Window, &App) -> AnyElement + 'static,
{
self.trigger = Some(Box::new(trigger));
self
}
/// Set the default open state of the popover, default is `false`. /// Set the default open state of the popover, default is `false`.
/// ///
/// This is only used to initialize the open state of the popover. /// This is only used to initialize the open state of the popover.
+56 -4
View File
@@ -2,9 +2,10 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
AnyElement, App, ClickEvent, Context, Decorations, Hsla, InteractiveElement, IntoElement, AnyElement, App, ClickEvent, Context, Decorations, Div, Hsla, InteractiveElement, IntoElement,
MouseButton, ParentElement, Pixels, Render, RenderOnce, StatefulInteractiveElement as _, MouseButton, ParentElement, Pixels, Render, RenderOnce, Stateful,
StyleRefinement, Styled, TitlebarOptions, Window, WindowControlArea, div, px, StatefulInteractiveElement as _, StyleRefinement, Styled, TitlebarOptions, Window,
WindowControlArea, div, px,
}; };
use smallvec::SmallVec; use smallvec::SmallVec;
use theme::ActiveTheme; use theme::ActiveTheme;
@@ -210,10 +211,61 @@ impl RenderOnce for ControlIcon {
#[derive(IntoElement)] #[derive(IntoElement)]
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
struct WindowControls { pub(crate) struct WindowControls {
on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>>>, on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>>>,
} }
pub(crate) fn window_controls() -> WindowControls {
WindowControls {
on_close_window: None,
}
}
pub fn title_bar_drag_handlers(
this: Stateful<Div>,
window: &mut Window,
cx: &mut App,
) -> Stateful<Div> {
let state = window.use_state(cx, |_, _| TitleBarState { should_move: false });
let this = if cfg!(target_family = "wasm") {
this
} else {
this.window_control_area(WindowControlArea::Drag)
};
this.on_mouse_down_out(window.listener_for(&state, |state, _, _, _| {
state.should_move = false;
}))
.on_mouse_down(
MouseButton::Left,
window.listener_for(&state, |state, _, _, _| {
state.should_move = true;
}),
)
.on_mouse_up(
MouseButton::Left,
window.listener_for(&state, |state, _, _, _| {
state.should_move = false;
}),
)
.on_mouse_move(window.listener_for(&state, |state, _, window, _| {
if state.should_move {
state.should_move = false;
window.start_window_move();
}
}))
.on_click(|event, window, _| {
if event.click_count() == 2 {
if cfg!(target_os = "macos") {
window.titlebar_double_click();
} else {
window.zoom_window();
}
}
})
}
impl RenderOnce for WindowControls { impl RenderOnce for WindowControls {
fn render(self, window: &mut Window, _: &mut App) -> impl IntoElement { fn render(self, window: &mut Window, _: &mut App) -> impl IntoElement {
if cfg!(target_os = "macos") || cfg!(target_family = "wasm") { if cfg!(target_os = "macos") || cfg!(target_family = "wasm") {
+1
View File
@@ -12,6 +12,7 @@ state = { path = "../state" }
device = { path = "../device" } device = { path = "../device" }
chat = { path = "../chat" } chat = { path = "../chat" }
chat_ui = { path = "../chat_ui" } chat_ui = { path = "../chat_ui" }
community = { path = "../community" }
settings = { path = "../settings" } settings = { path = "../settings" }
person = { path = "../person" } person = { path = "../person" }
auto_update = { path = "../auto_update" } auto_update = { path = "../auto_update" }
+10 -2
View File
@@ -295,7 +295,11 @@ impl Screening {
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
.text_sm() .text_sm()
.hover(|this| this.bg(cx.theme().elevated_surface_background)) .hover(|this| this.bg(cx.theme().elevated_surface_background))
.child(Avatar::new(profile.avatar()).small()) .child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.small(),
)
.child(profile.name()), .child(profile.name()),
); );
} }
@@ -335,7 +339,11 @@ impl Render for Screening {
.items_center() .items_center()
.justify_center() .justify_center()
.text_center() .text_center()
.child(Avatar::new(profile.avatar()).large()) .child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.large(),
)
.child( .child(
div() div()
.font_semibold() .font_semibold()
+36 -109
View File
@@ -1,3 +1,4 @@
use std::rc::Rc;
use std::sync::Arc; use std::sync::Arc;
use ::settings::AppSettings; use ::settings::AppSettings;
@@ -8,8 +9,8 @@ use common::download_dir;
use device::{DeviceEvent, DeviceRegistry}; use device::{DeviceEvent, DeviceRegistry};
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
Action, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement, Action, AnyElement, App, AppContext, Context, Entity, InteractiveElement, IntoElement,
Render, SharedString, Styled, Subscription, Task, Window, div, px, ParentElement, Render, SharedString, Styled, Subscription, Task, Window, div, px,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::{PersonRegistry, shorten_pubkey}; use person::{PersonRegistry, shorten_pubkey};
@@ -17,17 +18,18 @@ use serde::Deserialize;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::{NostrRegistry, StateEvent}; use state::{NostrRegistry, StateEvent};
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry}; use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
use ui::dock::{self, ClosePanel, DockArea, DockLayout, DockPlacement, Panel, PanelHandle}; use ui::dock::{self, ClosePanel, DockArea, DockLayout, DockPlacement, Panel, PanelHandle};
use ui::menu::{DropdownMenu, PopupMenuItem}; use ui::menu::{DropdownMenu, PopupMenuItem};
use ui::notification::{Notification, NotificationKind}; use ui::notification::{Notification, NotificationKind};
use ui::{Icon, IconName, Root, Sizable, TitleBar, WindowExtension, h_flex, v_flex}; use ui::{Icon, IconName, Root, Sizable, WindowExtension, h_flex, v_flex};
use crate::dialogs::import::ImportIdentity; use crate::dialogs::import::ImportIdentity;
use crate::dialogs::restore::RestoreEncryption; use crate::dialogs::restore::RestoreEncryption;
use crate::dialogs::settings; use crate::dialogs::settings;
use crate::panels::{backup, contact_list, greeter, messaging_relays, profile, relay_list}; use crate::panels::{
backup, browse, contact_list, greeter, inbox, messaging_relays, profile, relay_list, search,
};
use crate::sidebar::Sidebar; use crate::sidebar::Sidebar;
mod dialogs; mod dialogs;
@@ -57,12 +59,16 @@ enum Command {
ShowSettings, ShowSettings,
ShowBackup, ShowBackup,
ShowContactList, ShowContactList,
ShowInbox,
ShowBrowse,
ShowSearch,
} }
pub struct Workspace { pub struct Workspace {
sidebar: Entity<Sidebar>, sidebar: Entity<Sidebar>,
/// App's Dock Area /// App's Dock Area
dock: Entity<DockArea>, dock: Entity<DockArea>,
title_bar_chrome: Rc<dock::TitleBarChrome>,
/// Async tasks /// Async tasks
tasks: Vec<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
@@ -78,7 +84,7 @@ impl Workspace {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let sidebar = cx.new(|cx| Sidebar::new(window, cx)); let sidebar = cx.new(|cx| Sidebar::new(window, cx));
let dock = dock::dock_area("coop", window, cx); let (dock, title_bar_chrome) = dock::dock_area("coop", window, cx);
let mut subscriptions = smallvec![]; let mut subscriptions = smallvec![];
@@ -225,6 +231,7 @@ impl Workspace {
Self { Self {
sidebar, sidebar,
dock, dock,
title_bar_chrome,
tasks: vec![], tasks: vec![],
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
@@ -294,6 +301,15 @@ impl Workspace {
cx, cx,
); );
} }
Command::ShowInbox => {
self.add_panel_to_dock(inbox::init(window, cx), DockPlacement::Center, window, cx);
}
Command::ShowBrowse => {
self.add_panel_to_dock(browse::init(window, cx), DockPlacement::Center, window, cx);
}
Command::ShowSearch => {
self.add_panel_to_dock(search::init(window, cx), DockPlacement::Center, window, cx);
}
Command::ShowBackup => { Command::ShowBackup => {
self.add_panel_to_dock(backup::init(window, cx), DockPlacement::Left, window, cx); self.add_panel_to_dock(backup::init(window, cx), DockPlacement::Left, window, cx);
} }
@@ -518,95 +534,14 @@ impl Workspace {
}); });
} }
fn titlebar_left(&mut self, cx: &mut Context<Self>) -> impl IntoElement { fn titlebar_right(_window: &mut Window, cx: &mut App) -> AnyElement {
let nostr = NostrRegistry::global(cx);
let current_user = nostr.read(cx).current_user();
h_flex()
.flex_shrink_0()
.gap_2()
.when_none(&current_user, |this| {
this.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Import your identity to continue")),
)
})
.when_some(current_user.as_ref(), |this, public_key| {
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(public_key, cx);
let avatar = profile.avatar();
let name = profile.name();
this.child(
Button::new("current-user")
.child(Avatar::new(avatar.clone()).xsmall())
.small()
.caret()
.compact()
.transparent()
.dropdown_menu(move |this, _window, cx| {
let avatar = avatar.clone();
let name = name.clone();
this.min_w(px(256.))
.item(PopupMenuItem::element(move |_window, cx| {
h_flex()
.gap_1p5()
.text_xs()
.text_color(cx.theme().text_muted)
.child(Avatar::new(avatar.clone()).xsmall())
.child(name.clone())
}))
.separator()
.menu_with_icon(
"Profile",
IconName::Profile,
Box::new(Command::ShowProfile),
)
.menu_with_icon(
"Contact List",
IconName::Book,
Box::new(Command::ShowContactList),
)
.menu_with_icon(
"Backup",
IconName::UserKey,
Box::new(Command::ShowBackup),
)
.menu_with_icon(
"Themes",
IconName::Sun,
Box::new(Command::ToggleTheme),
)
// Only offer in-app updates when auto-update is
// enabled (managed channels update themselves).
.when(AutoUpdater::is_available(cx), |this| {
this.separator().menu_with_icon(
"Check for Updates",
IconName::Device,
Box::new(Command::Update),
)
})
.menu_with_icon(
"Settings",
IconName::Settings,
Box::new(Command::ShowSettings),
)
}),
)
})
}
fn titlebar_right(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
let auto_updater = AutoUpdater::try_global(cx); let auto_updater = AutoUpdater::try_global(cx);
let chat = ChatRegistry::global(cx); let chat = ChatRegistry::global(cx);
let nip4e_enabled = AppSettings::get_nip4e(cx); let nip4e_enabled = AppSettings::get_nip4e(cx);
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let Some(public_key) = nostr.read(cx).current_user() else { let Some(public_key) = nostr.read(cx).current_user() else {
return div(); return div().into_any_element();
}; };
let persons = PersonRegistry::global(cx); let persons = PersonRegistry::global(cx);
@@ -635,11 +570,11 @@ impl Workspace {
.tooltip("Quit and relaunch into the installed update") .tooltip("Quit and relaunch into the installed update")
.small() .small()
.ghost() .ghost()
.on_click(cx.listener(|_this, _event, _window, cx| { .on_click(|_event, _window, cx| {
if let Some(auto_updater) = AutoUpdater::try_global(cx) { if let Some(auto_updater) = AutoUpdater::try_global(cx) {
auto_updater.update(cx, |this, cx| this.restart(cx)); auto_updater.update(cx, |this, cx| this.restart(cx));
} }
})), }),
) )
}) })
.when(nip4e_enabled, |this| { .when(nip4e_enabled, |this| {
@@ -764,6 +699,7 @@ impl Workspace {
) )
}), }),
) )
.into_any_element()
} }
} }
@@ -772,33 +708,24 @@ impl Render for Workspace {
let modal_layer = Root::render_modal_layer(window, cx); let modal_layer = Root::render_modal_layer(window, cx);
let notification_layer = Root::render_notification_layer(window, cx); let notification_layer = Root::render_notification_layer(window, cx);
self.title_bar_chrome.set_trailing(Self::titlebar_right);
div() div()
.id("workspace") .id("workspace")
.on_action(cx.listener(Self::on_command)) .on_action(cx.listener(Self::on_command))
.relative() .relative()
.size_full() .size_full()
.child( .child(
v_flex() h_flex()
.size_full() .size_full()
// Title Bar
.child( .child(
TitleBar::new() div()
.child(self.titlebar_left(cx)) .flex_shrink_0()
.child(self.titlebar_right(cx)), .h_full()
.w(SIDEBAR_WIDTH)
.child(self.sidebar.clone()),
) )
// Main .child(self.dock.clone()),
.child(
h_flex()
.size_full()
.child(
div()
.flex_shrink_0()
.h_full()
.w(SIDEBAR_WIDTH)
.child(self.sidebar.clone()),
)
.child(self.dock.clone()),
),
) )
// Notifications // Notifications
.children(notification_layer) .children(notification_layer)
+62
View File
@@ -0,0 +1,62 @@
use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Window,
};
use theme::ActiveTheme;
use ui::dock::{Panel, PanelEvent};
use ui::{Icon, IconName, Sizable, h_flex};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<BrowsePanel> {
cx.new(|cx| BrowsePanel::new(window, cx))
}
pub struct BrowsePanel {
name: SharedString,
focus_handle: FocusHandle,
}
impl BrowsePanel {
fn new(_window: &mut Window, cx: &mut App) -> Self {
Self {
name: "Browse".into(),
focus_handle: cx.focus_handle(),
}
}
}
impl Panel for BrowsePanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, cx: &App) -> AnyElement {
h_flex()
.gap_1p5()
.child(
Icon::new(IconName::Compass)
.small()
.text_color(cx.theme().icon_muted),
)
.child(self.name.clone())
.into_any_element()
}
}
impl EventEmitter<PanelEvent> for BrowsePanel {}
impl Focusable for BrowsePanel {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for BrowsePanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.size_full()
.justify_center()
.text_sm()
.text_color(cx.theme().text_muted)
.child(self.name.clone())
}
}
+5 -1
View File
@@ -239,7 +239,11 @@ impl ContactListPanel {
h_flex() h_flex()
.gap_2() .gap_2()
.text_sm() .text_sm()
.child(Avatar::new(profile.avatar()).small()) .child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.small(),
)
.child(profile.name()), .child(profile.name()),
) )
.child( .child(
+62
View File
@@ -0,0 +1,62 @@
use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Window,
};
use theme::ActiveTheme;
use ui::dock::{Panel, PanelEvent};
use ui::{Icon, IconName, Sizable, h_flex};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<InboxPanel> {
cx.new(|cx| InboxPanel::new(window, cx))
}
pub struct InboxPanel {
name: SharedString,
focus_handle: FocusHandle,
}
impl InboxPanel {
fn new(_window: &mut Window, cx: &mut App) -> Self {
Self {
name: "Inbox".into(),
focus_handle: cx.focus_handle(),
}
}
}
impl Panel for InboxPanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, cx: &App) -> AnyElement {
h_flex()
.gap_1p5()
.child(
Icon::new(IconName::Inbox)
.small()
.text_color(cx.theme().icon_muted),
)
.child(self.name.clone())
.into_any_element()
}
}
impl EventEmitter<PanelEvent> for InboxPanel {}
impl Focusable for InboxPanel {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for InboxPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.size_full()
.justify_center()
.text_sm()
.text_color(cx.theme().text_muted)
.child(self.name.clone())
}
}
+3
View File
@@ -1,6 +1,9 @@
pub mod backup; pub mod backup;
pub mod browse;
pub mod contact_list; pub mod contact_list;
pub mod greeter; pub mod greeter;
pub mod inbox;
pub mod messaging_relays; pub mod messaging_relays;
pub mod profile; pub mod profile;
pub mod relay_list; pub mod relay_list;
pub mod search;
+2 -7
View File
@@ -309,12 +309,7 @@ impl Render for ProfilePanel {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
let avatar_input = self.avatar_input.read(cx).value(); let avatar_input = self.avatar_input.read(cx).value();
// Get the avatar let picture = (!avatar_input.is_empty()).then_some(avatar_input);
let avatar = if avatar_input.is_empty() {
"brand/avatar.png"
} else {
avatar_input.as_str()
};
// Get the public key as short string // Get the public key as short string
let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8)); let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
@@ -331,7 +326,7 @@ impl Render for ProfilePanel {
.items_center() .items_center()
.justify_center() .justify_center()
.gap_4() .gap_4()
.child(Avatar::new(avatar).large()) .child(Avatar::new(picture).seed(self.public_key.to_hex()).large())
.child( .child(
Button::new("upload") Button::new("upload")
.icon(IconName::PlusCircle) .icon(IconName::PlusCircle)
+541
View File
@@ -0,0 +1,541 @@
use std::collections::HashSet;
use std::ops::Range;
use anyhow::Error;
use chat::{ChatRegistry, Room, RoomKind};
use common::DebouncedDelay;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window, div,
uniform_list,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use smallvec::{SmallVec, smallvec};
use state::{FIND_DELAY, NostrRegistry};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::dock::{Panel, PanelEvent};
use ui::input::{Input, InputEvent, InputState};
use ui::notification::Notification;
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
use crate::sidebar::RoomEntry;
const INPUT_PLACEHOLDER: &str = "Find or start a conversation";
pub fn init(window: &mut Window, cx: &mut App) -> Entity<SearchPanel> {
cx.new(|cx| SearchPanel::new(window, cx))
}
pub struct SearchPanel {
name: SharedString,
focus_handle: FocusHandle,
/// Find input state
find_input: Entity<InputState>,
/// Debounced delay for find input
find_debouncer: DebouncedDelay<Self>,
/// Whether a search is in progress
finding: bool,
/// Find results
find_results: Entity<Option<Vec<PublicKey>>>,
/// Async find operation
find_task: Option<Task<Result<(), Error>>>,
/// Selected public keys
selected_pkeys: Entity<HashSet<PublicKey>>,
/// User's contacts
contact_list: Entity<Option<Vec<PublicKey>>>,
/// Async tasks
tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl SearchPanel {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let contact_list = cx.new(|_| None);
let selected_pkeys = cx.new(|_| HashSet::new());
let find_results = cx.new(|_| None);
let find_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(INPUT_PLACEHOLDER)
.clean_on_escape()
});
let mut subscriptions = smallvec![];
subscriptions.push(
// Subscribe to find input events
cx.subscribe_in(&find_input, window, |this, state, event, window, cx| {
let delay = Duration::from_millis(FIND_DELAY);
match event {
InputEvent::PressEnter { .. } => {
this.search(window, cx);
}
InputEvent::Change => {
if state.read(cx).value().is_empty() {
// Clear results when input is empty
this.reset(window, cx);
} else {
// Run debounced search
this.find_debouncer
.fire_new(delay, window, cx, |this, window, cx| {
this.debounced_search(window, cx)
});
}
}
InputEvent::Focus => {
this.get_contact_list(window, cx);
}
_ => {}
};
}),
);
Self {
name: "Search".into(),
focus_handle: cx.focus_handle(),
find_input,
find_debouncer: DebouncedDelay::new(),
find_results,
find_task: None,
finding: false,
contact_list,
selected_pkeys,
tasks: smallvec![],
_subscriptions: subscriptions,
}
}
/// Get the contact list.
fn get_contact_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let Some(public_key) = nostr.read(cx).current_user() else {
return;
};
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
let filter = Filter::new()
.author(public_key)
.kind(Kind::ContactList)
.limit(1);
let contacts: HashSet<PublicKey> = client
.database()
.query(filter)
.await?
.into_iter()
.next()
.map(|event| event.tags.public_keys().collect())
.unwrap_or_default();
Ok(contacts)
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(contacts) => {
this.update(cx, |this, cx| {
this.set_contact_list(contacts, cx);
})?;
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(
Notification::error(e.to_string()).autohide(false),
cx,
);
})?;
}
};
Ok(())
}));
}
/// Set the contact list with new contacts.
fn set_contact_list<I>(&mut self, contacts: I, cx: &mut Context<Self>)
where
I: IntoIterator<Item = PublicKey>,
{
self.contact_list.update(cx, |this, cx| {
*this = Some(contacts.into_iter().collect());
cx.notify();
});
}
/// Trigger the debounced search
fn debounced_search(&self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
cx.spawn_in(window, async move |this, cx| {
this.update_in(cx, |this, window, cx| {
this.search(window, cx);
})
.ok();
})
}
/// Search
fn search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Get query
let query = self.find_input.read(cx).value();
// Return if the query is empty
if query.is_empty() {
return;
}
// Block the input until the search completes
self.set_finding(true, window, cx);
// Create the search task
let nostr = NostrRegistry::global(cx);
let find_users = nostr.read(cx).search(&query, cx);
// Run task in the main thread
self.find_task = Some(cx.spawn_in(window, async move |this, cx| {
let rooms = find_users.await?;
// Update the UI with the search results
this.update_in(cx, |this, window, cx| {
this.set_results(rooms, cx);
this.set_finding(false, window, cx);
})?;
Ok(())
}));
}
/// Set the results of the search
fn set_results(&mut self, results: Vec<PublicKey>, cx: &mut Context<Self>) {
self.find_results.update(cx, |this, cx| {
*this = Some(results);
cx.notify();
});
}
/// Set the finding status
fn set_finding(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
// Disable the input to prevent duplicate requests
self.find_input.update(cx, |this, cx| {
this.set_loading(status, window, cx);
});
// Set the search status
self.finding = status;
cx.notify();
}
fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Clear all search results
self.find_results.update(cx, |this, cx| {
*this = None;
cx.notify();
});
// Clear all selected public keys
self.selected_pkeys.update(cx, |this, cx| {
this.clear();
cx.notify();
});
// Reset the search status
self.set_finding(false, window, cx);
// Cancel the current search task
self.find_task = None;
cx.notify();
}
/// Select a public key in the search panel.
fn select(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
self.selected_pkeys.update(cx, |this, cx| {
if this.contains(public_key) {
this.remove(public_key);
} else {
this.insert(public_key.to_owned());
}
cx.notify();
});
}
/// Check if a public key is selected in the search panel.
fn is_selected(&self, public_key: &PublicKey, cx: &App) -> bool {
self.selected_pkeys.read(cx).contains(public_key)
}
/// Get all selected public keys in the search panel.
fn get_selected(&self, cx: &Context<Self>) -> HashSet<PublicKey> {
self.selected_pkeys.read(cx).clone()
}
/// Create a new room
fn create_room(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let chat = ChatRegistry::global(cx);
let async_chat = chat.downgrade();
let nostr = NostrRegistry::global(cx);
let Some(public_key) = nostr.read(cx).current_user() else {
return;
};
// Get all selected public keys
let receivers = self.get_selected(cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
// Create a new room and emit it
async_chat.update_in(cx, |this, _window, cx| {
let room = cx.new(|_| {
Room::new(public_key, receivers)
.organize(&public_key)
.kind(RoomKind::Ongoing)
});
this.emit_room(&room, _window, cx);
})?;
// Reset the find panel
this.update_in(cx, |this, window, cx| {
this.reset(window, cx);
})?;
Ok(())
}));
}
/// Render the search results
fn render_results(
&self,
range: Range<usize>,
cx: &Context<Self>,
) -> Vec<impl IntoElement + use<>> {
let persons = PersonRegistry::global(cx);
// Get the results
let Some(results) = self.find_results.read(cx) else {
return vec![];
};
// Map the results to a list of elements
results
.get(range.clone())
.into_iter()
.flatten()
.enumerate()
.map(|(ix, public_key)| {
let selected = self.is_selected(public_key, cx);
let profile = persons.read(cx).get(public_key, cx);
let pkey_clone = public_key.to_owned();
let handler = cx.listener(move |this, _ev, _window, cx| {
this.select(&pkey_clone, cx);
});
RoomEntry::new(range.start + ix)
.name(profile.name())
.avatar(profile.avatar())
.seed(profile.avatar_seed())
.on_click(handler)
.selected(selected)
.into_any_element()
})
.collect()
}
/// Render the contact list
fn render_contacts(
&self,
range: Range<usize>,
cx: &Context<Self>,
) -> Vec<impl IntoElement + use<>> {
let persons = PersonRegistry::global(cx);
// Get the contact list
let Some(contacts) = self.contact_list.read(cx) else {
return vec![];
};
// Map the contact list to a list of elements
contacts
.get(range.clone())
.into_iter()
.flatten()
.enumerate()
.map(|(ix, public_key)| {
let selected = self.is_selected(public_key, cx);
let profile = persons.read(cx).get(public_key, cx);
let pkey_clone = public_key.to_owned();
let handler = cx.listener(move |this, _ev, _window, cx| {
this.select(&pkey_clone, cx);
});
RoomEntry::new(range.start + ix)
.name(profile.name().trim())
.avatar(profile.avatar())
.seed(profile.avatar_seed())
.on_click(handler)
.selected(selected)
.into_any_element()
})
.collect()
}
}
impl Panel for SearchPanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, cx: &App) -> AnyElement {
h_flex()
.gap_1p5()
.child(
Icon::new(IconName::Search)
.small()
.text_color(cx.theme().icon_muted),
)
.child(self.name.clone())
.into_any_element()
}
}
impl EventEmitter<PanelEvent> for SearchPanel {}
impl Focusable for SearchPanel {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for SearchPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let nostr = NostrRegistry::global(cx);
let chat = ChatRegistry::global(cx);
let logged_in = nostr.read(cx).current_user().is_some();
let loading = chat.read(cx).loading() && logged_in;
// Set button label based on total selected users
let button_label = if self.selected_pkeys.read(cx).len() > 1 {
"Create Group DM"
} else {
"Create DM"
};
v_flex()
.size_full()
.gap_3()
.p_2()
.child(
h_flex().child(
Input::new(&self.find_input)
.small()
.text_xs()
.disabled(loading)
.when(
!self.find_input.read(cx).presentation().is_loading(),
|this| {
this.suffix(
Button::new("find-icon")
.icon(IconName::Search)
.tooltip("Press Enter to search")
.transparent()
.small(),
)
},
),
),
)
.child(
v_flex()
.flex_1()
.gap_3()
.when_some(self.find_results.read(cx).as_ref(), |this, results| {
this.child(
v_flex()
.gap_1()
.flex_1()
.border_b_1()
.border_color(cx.theme().border_variant)
.child(
h_flex()
.gap_0p5()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(Icon::new(IconName::ChevronDown))
.child("Results"),
)
.child(
uniform_list(
"rooms",
results.len(),
cx.processor(move |this, range, _window, cx| {
this.render_results(range, cx)
}),
)
.flex_1()
.h_full(),
),
)
})
.when_some(self.contact_list.read(cx).as_ref(), |this, contacts| {
this.child(
v_flex()
.gap_1()
.flex_1()
.child(
h_flex()
.gap_0p5()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(Icon::new(IconName::ChevronDown).small())
.child("Contacts"),
)
.child(
uniform_list(
"contacts",
contacts.len(),
cx.processor(|this, range, _window, cx| {
this.render_contacts(range, cx)
}),
)
.flex_1()
.h_full(),
),
)
}),
)
.when(!self.selected_pkeys.read(cx).is_empty(), |this| {
this.child(
div()
.absolute()
.bottom_2()
.left_0()
.h_9()
.w_full()
.px_4()
.child(
Button::new("create")
.label(button_label)
.primary()
.rounded()
.shadow_md()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.create_room(window, cx);
})),
),
)
})
}
}
+30 -10
View File
@@ -4,7 +4,7 @@ use chat::RoomKind;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce, SharedString, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce, SharedString,
StatefulInteractiveElement, Styled, Window, div, StatefulInteractiveElement, Styled, Window, div, px,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use settings::AppSettings; use settings::AppSettings;
@@ -22,8 +22,10 @@ pub struct RoomEntry {
public_key: Option<PublicKey>, public_key: Option<PublicKey>,
name: Option<SharedString>, name: Option<SharedString>,
avatar: Option<SharedString>, avatar: Option<SharedString>,
seed: Option<SharedString>,
created_at: Option<SharedString>, created_at: Option<SharedString>,
kind: Option<RoomKind>, kind: Option<RoomKind>,
depth: u8,
selected: bool, selected: bool,
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>, handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
@@ -36,8 +38,10 @@ impl RoomEntry {
public_key: None, public_key: None,
name: None, name: None,
avatar: None, avatar: None,
seed: None,
created_at: None, created_at: None,
kind: None, kind: None,
depth: 0,
handler: None, handler: None,
selected: false, selected: false,
} }
@@ -53,8 +57,13 @@ impl RoomEntry {
self self
} }
pub fn avatar(mut self, avatar: impl Into<SharedString>) -> Self { pub fn avatar(mut self, picture: Option<SharedString>) -> Self {
self.avatar = Some(avatar.into()); self.avatar = picture;
self
}
pub fn seed(mut self, seed: impl Into<SharedString>) -> Self {
self.seed = Some(seed.into());
self self
} }
@@ -68,6 +77,11 @@ impl RoomEntry {
self self
} }
pub fn depth(mut self, depth: u8) -> Self {
self.depth = depth;
self
}
pub fn on_click( pub fn on_click(
mut self, mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
@@ -95,20 +109,26 @@ impl RenderOnce for RoomEntry {
let public_key = self.public_key; let public_key = self.public_key;
let is_selected = self.is_selected(); let is_selected = self.is_selected();
let avatar = match (self.avatar, self.seed) {
(None, None) => None,
(picture, seed) => Some(
Avatar::new(picture)
.when_some(seed, |avatar, seed| avatar.seed(seed))
.xsmall()
.flex_shrink_0(),
),
};
h_flex() h_flex()
.id(self.ix) .id(self.ix)
.h_9() .h_8()
.w_full() .w_full()
.px_1p5() .pl(px(6. + self.depth as f32 * 10.))
.pr_1p5()
.gap_2() .gap_2()
.text_sm() .text_sm()
.rounded(cx.theme().radius) .rounded(cx.theme().radius)
.when(!hide_avatar, |this| { .when(!hide_avatar, |this| this.children(avatar))
this.when_some(self.avatar, |this, avatar| {
this.child(Avatar::new(avatar).small().flex_shrink_0())
})
})
.child( .child(
div() div()
.flex_1() .flex_1()
File diff suppressed because it is too large Load Diff
+246
View File
@@ -0,0 +1,246 @@
use std::path::PathBuf;
use std::rc::Rc;
use chat::Room;
use community::Community;
use gpui::prelude::FluentBuilder;
use gpui::{
App, ClickEvent, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce,
SharedString, StatefulInteractiveElement, Styled, Window, div, px,
};
use theme::ActiveTheme;
use ui::avatar::{Avatar, PixelAvatar};
use ui::{Icon, IconName, Sizable, StyledExt, h_flex};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum TreeSection {
Pins,
Requests,
Community,
Messages,
}
impl TreeSection {
pub fn label(self) -> &'static str {
match self {
Self::Pins => "Pinned",
Self::Requests => "Requests",
Self::Community => "Community",
Self::Messages => "Messages",
}
}
pub fn icon(self) -> IconName {
match self {
Self::Pins | Self::Requests | Self::Community => IconName::Folder,
Self::Messages => IconName::Message,
}
}
pub fn key(self) -> &'static str {
match self {
Self::Pins => "pins",
Self::Requests => "requests",
Self::Community => "community",
Self::Messages => "messages",
}
}
pub fn from_key(key: &str) -> Option<Self> {
match key {
"pins" => Some(Self::Pins),
"requests" => Some(Self::Requests),
"community" => Some(Self::Community),
"messages" => Some(Self::Messages),
_ => None,
}
}
}
pub enum SidebarRow {
Section {
section: TreeSection,
count: usize,
},
Room {
room: Entity<Room>,
depth: u8,
pinned: bool,
},
Community {
community: Entity<Community>,
depth: u8,
},
NewCommunity {
depth: u8,
},
Hint {
text: SharedString,
depth: u8,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TreeRowKind {
Section,
Community,
Hint,
}
#[derive(IntoElement)]
pub struct TreeRow {
id: ElementId,
kind: TreeRowKind,
depth: u8,
caret: Option<IconName>,
icon: Option<IconName>,
avatar: Option<SharedString>,
picture: Option<PathBuf>,
label: SharedString,
count: Option<usize>,
dot: bool,
#[allow(clippy::type_complexity)]
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
}
impl TreeRow {
pub fn new(
id: impl Into<ElementId>,
kind: TreeRowKind,
label: impl Into<SharedString>,
) -> Self {
Self {
id: id.into(),
kind,
depth: 0,
caret: None,
icon: None,
avatar: None,
picture: None,
label: label.into(),
count: None,
dot: false,
on_click: None,
}
}
pub fn depth(mut self, depth: u8) -> Self {
self.depth = depth;
self
}
pub fn caret(mut self, caret: IconName) -> Self {
self.caret = Some(caret);
self
}
pub fn icon(mut self, icon: IconName) -> Self {
self.icon = Some(icon);
self
}
/// Sets the seed for the row's generated avatar.
pub fn avatar(mut self, seed: impl Into<SharedString>) -> Self {
self.avatar = Some(seed.into());
self
}
/// Shows `picture` instead of the generated avatar.
pub fn picture(mut self, picture: Option<PathBuf>) -> Self {
self.picture = picture;
self
}
pub fn count(mut self, count: usize) -> Self {
self.count = Some(count);
self
}
pub fn dot(mut self) -> Self {
self.dot = true;
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_click = Some(Rc::new(handler));
self
}
}
impl RenderOnce for TreeRow {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let indent = px(6. + self.depth as f32 * 14.);
let is_section = self.kind == TreeRowKind::Section;
let is_community = self.kind == TreeRowKind::Community;
let is_hint = self.kind == TreeRowKind::Hint;
let avatar = match (self.avatar, self.picture) {
(seed, Some(picture)) => Some(
Avatar::from_source(picture)
.when_some(seed, |avatar, seed| avatar.seed(seed))
.xsmall()
.into_any_element(),
),
(Some(seed), None) => Some(PixelAvatar::new(seed).xsmall().into_any_element()),
(None, None) => None,
};
h_flex()
.id(self.id)
.h_8()
.w_full()
.pl(indent)
.pr_1p5()
.gap_2()
.rounded(cx.theme().radius)
.when(is_section, |this| {
this.text_xs().text_color(cx.theme().text_muted)
})
.when(is_community, |this| this.text_sm())
.when(is_hint, |this| {
this.text_xs()
.font_normal()
.text_color(cx.theme().text_placeholder)
})
.when_some(self.icon, |this, icon| {
this.child(Icon::new(icon).small().text_color(cx.theme().icon_muted))
})
.when_some(avatar, |this, avatar| this.child(avatar))
.child(
h_flex()
.gap_1()
.flex_1()
.child(div().truncate().min_w_0().child(self.label))
.when_some(self.count, |this, count| {
this.child(
div()
.flex_shrink_0()
.text_xs()
.text_color(cx.theme().text_placeholder)
.font_semibold()
.child(count.to_string()),
)
}),
)
.when_some(self.caret, |this, caret| {
this.child(Icon::new(caret).xsmall().text_color(cx.theme().icon_muted))
})
.when(self.dot, |this| {
this.child(
div()
.flex_shrink_0()
.size_1()
.rounded_full()
.bg(cx.theme().cursor),
)
})
.when_some(self.on_click, |this, handler| {
this.cursor_pointer()
.hover(|this| this.bg(cx.theme().ghost_element_hover))
.on_click(move |event, window, cx| handler(event, window, cx))
})
}
}
+1
View File
@@ -35,6 +35,7 @@ common = { path = "../crates/common" }
state = { path = "../crates/state" } state = { path = "../crates/state" }
device = { path = "../crates/device" } device = { path = "../crates/device" }
chat = { path = "../crates/chat" } chat = { path = "../crates/chat" }
community = { path = "../crates/community" }
settings = { path = "../crates/settings" } settings = { path = "../crates/settings" }
auto_update = { path = "../crates/auto_update" } auto_update = { path = "../crates/auto_update" }
person = { path = "../crates/person" } person = { path = "../crates/person" }
+35 -30
View File
@@ -9,6 +9,7 @@ use gpui::{
use gpui_platform::application; use gpui_platform::application;
use nostr_sdk::prelude::SecretKey; use nostr_sdk::prelude::SecretKey;
use state::{APP_ID, CLIENT_NAME}; use state::{APP_ID, CLIENT_NAME};
use theme::TABBAR_HEIGHT;
use ui::Root; use ui::Root;
actions!(coop, [Quit]); actions!(coop, [Quit]);
@@ -30,6 +31,12 @@ fn main() {
.with_assets(Assets) .with_assets(Assets)
.with_http_client(Arc::new(reqwest_client::ReqwestClient::new())) .with_http_client(Arc::new(reqwest_client::ReqwestClient::new()))
.run(move |cx| { .run(move |cx| {
// Initialize components
ui::init(cx);
// Initialize theme registry
theme::init(cx);
// Load embedded fonts in assets/fonts // Load embedded fonts in assets/fonts
load_embedded_fonts(cx); load_embedded_fonts(cx);
@@ -54,6 +61,29 @@ fn main() {
disabled: false, disabled: false,
}]); }]);
// Initialize settings
settings::init(cx);
// Initialize the nostr client
state::init(cx, cli_key);
// Initialize person registry
person::init(cx);
// Initialize device signer
//
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
device::init(cx);
// Initialize app registry
chat::init(cx);
// Initialize community registry
community::init(cx);
// Initialize auto update
auto_update::init(cx);
// Set up the window bounds // Set up the window bounds
let bounds = Bounds::centered(None, size(px(960.0), px(720.0)), cx); let bounds = Bounds::centered(None, size(px(960.0), px(720.0)), cx);
@@ -66,46 +96,21 @@ fn main() {
app_id: Some(APP_ID.to_owned()), app_id: Some(APP_ID.to_owned()),
titlebar: Some(TitlebarOptions { titlebar: Some(TitlebarOptions {
title: Some(SharedString::new_static(CLIENT_NAME)), title: Some(SharedString::new_static(CLIENT_NAME)),
traffic_light_position: Some(point(px(9.0), px(9.0))), traffic_light_position: Some(point(
px(9.0),
px(TABBAR_HEIGHT / px(2.) - 14. / 2.),
)),
appears_transparent: true, appears_transparent: true,
}), }),
app_owns_titlebar_drag: true,
..Default::default() ..Default::default()
}; };
// Open a window with default options
cx.open_window(opts, |window, cx| { cx.open_window(opts, |window, cx| {
// Initialize components
ui::init(cx);
// Initialize theme registry
theme::init(cx);
// Initialize settings
settings::init(window, cx);
// Initialize the nostr client
state::init(window, cx, cli_key);
// Initialize person registry
person::init(window, cx);
// Initialize device signer
//
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
device::init(window, cx);
// Initialize app registry
chat::init(window, cx);
// Initialize auto update
auto_update::init(window, cx);
// Root view
cx.new(|cx| Root::new(workspace::init(window, cx).into(), window, cx)) cx.new(|cx| Root::new(workspace::init(window, cx).into(), window, cx))
}) })
.expect("Failed to open window. Please restart the application."); .expect("Failed to open window. Please restart the application.");
// Bring the app to the foreground
cx.activate(true); cx.activate(true);
}); });
} }
+336
View File
@@ -0,0 +1,336 @@
# Concord discovery: why no community ever reaches `subscribe`
Audit + fix plan. Read alongside `docs/concord-usage.md` and
`docs/concord-simplification-plan.md`.
## Symptom
`crates/community/src/lib.rs::subscribe` is never called, so no wrap is ever
subscribed to and the sidebar stays empty. `community load: 0 state document(s)
found` is the only clue.
## Root cause
`CommunityRegistry::load` only ever reads the **local database**. Nothing in the
discovery path touches a relay.
```
community::init
└─ SignerChanged → load
└─ sync::load
├─ store::load_states(client) → client.database().query(..) // local only
└─ load_list(client, ..) → client.database().query(..) // local only, .limit(1)
→ track([])
→ sync_subscriptions: `for community in self.communities` runs zero times
→ subscribe never called
→ no relay is ever queried
→ the database never fills
→ load stays empty forever
```
The loop is self-reinforcing: the local database is populated *by* the
subscriptions that the empty load prevents. That is why an account which belongs
to several communities in another client still shows nothing — a fresh install
has no `concord/*` state document, and coop has no way to ask for one.
Confirmed by inspection:
| Location | What it does |
| --- | --- |
| `crates/community/src/lib.rs:167-191` | `load``sync::load`, then `track(states)` |
| `crates/community/src/sync.rs:125-137` | `load` = `store::load_states` + `load_list` |
| `crates/concord/src/store.rs:314-341` | `load_states` queries `client.database()` only |
| `crates/community/src/sync.rs:139-153` | `load_list` queries `client.database()` only, `.limit(1)` |
| `crates/community/src/lib.rs:233-276` | `sync_subscriptions` skips everything when `communities` is empty |
`subscribe` itself is correct. Do not debug it.
## What the protocol actually says
Read from the spec (`concord-protocol/concord`, the submodule referenced by
accordion.chat): `02.md` §8 and `examples.md` §6.2.
A member's memberships live in the **Community List**, on relays:
- **Kind `33302`**, addressable, NIP-44-encrypted to self, signed by the
member's real key, one event per **fragment** with `d` = the fragment index in
decimal (`"0"`, `"1"`, …). `13302` is explicitly **retired** ("the
single-event Community List, superseded by `33302` once it outgrew one event —
a replaceable kind cannot fragment", `02.md:314`).
- Every 32-byte value at **any depth** is unpadded base64url, not hex. This is
section-scoped: CORD-05 invite fields stay hex (`examples.md` §6.3).
- Join material is the membership subset — `owner, owner_salt, community_root,
root_epoch, control_pk, channels, relays, name`, plus `control_root` when
held. It is the *only* durable home of a member's keys.
- The two snapshots solve opposite problems: `seed` is the earliest epoch held
(backfill anchor), `current` the latest ("so a fresh device reconstructs the
Community instantly with no epoch-by-epoch walk"). `seed` is omitted when
equal to `current`; embedded snapshots omit `community_id` (inherited).
- A client holds the complete List when it holds a fragment at every index below
`frags`; it unions fragments and merges, so a partial read is safe.
Two consequences for coop:
1. **The state document is a coop invention.** `store::{save_state, load_state,
load_states}` write kind `30078` with `d = concord/<id>`, signed by a
per-process `LOCAL_KEYS`, and never leave the machine. No equivalent exists
anywhere in the spec. It is a local cache and must never be treated as the
discovery source.
2. **Discovery is: subscribe to my `33302` → materialize a community from
`current` join material → subscribe to its planes → fold.** The fold produces
the authoritative state; the List only supplies the keys to start.
## Divergences (coop vs spec)
| # | Spec | coop today |
| --- | --- | --- |
| 1 | kind `33302`, addressable | was `cord02::list::KIND_COMMUNITY_LIST = 13302` (retired) — **fixed in Phase A** |
| 2 | one event per fragment, `d` = index, `frags` declared | was no `frags`, single event, `d` unused, `load_list` `.limit(1)`**fixed in Phase A** |
| 3 | 32-byte values unpadded base64url at any depth | was hex for `JoinMaterial.owner`/`control_root`, `CommunityId` serde, `ChannelGrant.key`**fixed in Phase A** |
| 4 | `seed` omitted when equal to `current`; embedded snapshot omits `community_id`; `seed`'s cosmetic fields rewritten from `current` | was both snapshots emitted verbatim, `community_id` always present — **fixed in Phase A** |
| 5 | fetch from relays | local database only — **fixed in Phase C** |
| 6 | materialize `CommunityState` from join material | was no such path; only `CommunityState::from_genesis`**fixed in Phase B** |
| 7 | publish the List on create/join (read-modify-write) | `build_list_event` is referenced only by tests and docs — **fixed in Phase D** |
| 8 | private channel keys ride in join material | `ChannelKeyRef` has a key field, but private planes are still not subscribed |
Divergences 17 are resolved. 8 remains, in the narrow sense that `planes()`
still skips private channels rather than deriving their addresses from the
granted key.
## Plan
Ordered so each phase is independently reviewable and testable. Nothing here
touches the frozen HKDF derivations or `cord01` envelope semantics.
### Phase A — make the List interoperable (pure, no I/O) — DONE
`crates/concord/src/cords/cord02/list.rs`
1. `KIND_COMMUNITY_LIST``33302`; add `frags: u64` to `CommunityList` and
`is_complete(&self, frags) -> bool`.
2. Add a base64url codec for the §8 value set and apply it to every 32-byte
field at every depth. Because `JoinMaterial` currently types `owner` and
`control_pk` as `PublicKey` (nostr's hex serde), this needs either wire
newtypes or `serialize_with`/`deserialize_with` helpers. Keep it local to the
List: `cord05` stays hex.
3. Implement the two §8 MUSTs: omit `community_id` on an embedded snapshot,
omit `seed` when it byte-equals `current`, and rewrite `seed`'s cosmetic
fields (`name`, `relays`, each channel's `name`) from `current` on every
serialization.
4. `build_list_event`/`parse_list_event` take the fragment index and emit/read
the `d` tag.
Tests: round-trip the `examples.md` §6.2 payload verbatim; `merge` convergence
for two devices and mixed-age fragments; `frags` disagreement resolves to the
larger value; a repack does not shed unknown fields.
**As built.** The §8 rules live behind private wire structs (`WireList`,
`WireEntry`, `WireSnapshot`, `WireChannel`), so a writer re-encodes on every
serialization while the public types keep their internal hex/`PublicKey`
spellings and `cord05` stays hex. Three deviations from the sketch above:
- `is_complete` takes the set of fragment indices a client holds, not a count:
a count is wrong when the indices are sparse.
- The reader tolerates non-zero base64url trailing bits. The spec's own §6.2
example has five such values, so a strict decoder rejects the worked example;
the writer still emits the canonical spelling.
- The third omission MUST was implemented too: an entry whose `added_at` does
not outrun its tombstone is not written. It is a serialization rule exactly
like the other two, so it belongs here rather than in Phase D.
`parse_list_event` validates the `d` tag but returns just the `CommunityList`;
`fragment_index(event)` reads the index, which kept `sync.rs` untouched until
Phase C. `MAX_MEMBERSHIPS = 50` is kept as a stopgap (see risks): §8 has no
membership limit, and removing the cap needs write-time fragmentation.
### Phase B — materialize a community from join material (pure) — DONE
`crates/concord/src/store.rs`, `crates/concord/src/cords/cord02/list.rs`
1. `CommunityState::from_join_material(material: &JoinMaterial, added_at_ms:
u64) -> Result<Self>`: identity/owner/salt/root/root_epoch from the material;
`control_pks = { root_epoch → control_pk }`; `relays` parsed; `channels` from
the grants; `control_root` when present; `heads` empty (the first control
fold fills them); `banned` empty; `dissolved` false.
2. Carry the private channel key: add `key: Option<[u8; 32]>` to
`ChannelKeyRef` (or a parallel map) so a grant's `key` has a home. Without
this, a private channel is silently read-only-until-rekey.
Tests: a material with and without `control_root`; a private grant's key
survives; `from_join_material` then `planes()` yields the control `control_pk`
plus the guestbook and public channels, i.e. a subscription filter that
addresses real planes.
**As built.** `from_join_material` does not verify `community_id` against
`owner`/`owner_salt`: the List is signed by the member's own key and encrypted
to self, and the invite path already validates that binding in
`CommunityInvite::validate`. `private` on a materialized channel is simply
`key.is_some()` — the spec's `channels` carry only the Private Channel keys a
member was granted, so a grant with no key is a public channel. Nothing else
changed: `from_genesis` and `apply_fold` construct every channel with
`key: None`, and `planes()` still skips private channels, whose address derives
from the granted key rather than the `community_root`. Carrying the key is what
makes subscribing to them possible later; it is not needed to fix discovery.
Two tests. In `concord`, `from_join_material` (with and without `control_root`,
a granted key surviving, a public grant staying keyless). In `community`,
`planes()` plus `subscription_filter` over a state built field-by-field (control
+ guestbook + public channel addressed, private skipped) — `JoinMaterial` and
`ChannelGrant` cannot be constructed from `community` because their `extra`
field's type is crate-private, so the materialization and the plane derivation
are each proved where they live.
### Phase C — the List drives `load` — DONE
`crates/community/src/sync.rs`, `crates/community/src/lib.rs`
1. `subscribe_list(client, self_pk)` subscribes to `Kind::Custom(33302)`
`author(self_pk)` under a dedicated `concord/list` subscription id, using
`ReqTarget::auto`. With gossip enabled, `auto` breaks the filter down by
author, so it queries the account's NIP-65 write relays and adds/connects
them itself — bootstrap relays alone would miss a List published elsewhere.
2. `CommunityRegistry` calls `subscribe_list` once per signer (signer change and
the initial defer). It is deliberately **not** called from `load`:
re-subscribing on every List event would re-deliver the List and loop. `reset`
does not unsubscribe it either — `subscribe_list` replaces the subscription
itself, and a `reset`-issued unsubscribe could race the replacement and cancel
discovery.
3. The notification listener routes a `concord/list` event to a new `Signal::List`,
whose consumer re-runs `load`. Community planes keep using `Signal::Event(id)`.
4. `load_list` reads every `33302` event by `self_pk` from the database, keeps the
newest event per fragment index, decrypts and `merge`s them. `.limit(1)` is gone.
An incomplete List is read normally — a missing fragment is news not yet heard.
5. `load` unions two sources: every live List entry (materialized with
`from_join_material`, or refreshed if a state document already exists) and every
held local state the List does not mention. A held membership is dropped only
when a tombstone outranks its `added_at_ms`; absence from the List is never a
fact. Each list-derived state is `save_state`d, so the next `load` is warm.
6. `refresh(held, fresh)` keeps the fold's authority (`heads`, `banned`,
`dissolved`) and the control planes it learned, and takes the List's identity,
relays, and channel keys. Channels are merged by id rather than replaced, so a
public channel the fold discovered is not shed by a List snapshot that predates
it.
**As built, deviating from the sketch above.** The plan called for
`client.fetch_events(..)`; the SDK's own recommendation is to keep the request
path on a subscription and read the database. This is safer than it sounds: a
relay's event is persisted at `nostr-sdk/src/relay/inner.rs:1291` **before** the
notification is emitted, so a subscription plus a database read loses nothing and
needs no explicit save. The subscription is set up with `ReqTarget::auto` rather
than a hand-built NIP-65 relay map, because gossip already resolves the author's
write relays and connects them on demand.
Tests (no network, in `crates/community/src/sync.rs`): a membership the List
carries materializes a community even though no state document was ever written
for it, and discovery writes the document so the next load is warm; a held
membership the List never mentions is kept alongside the one it does; a tombstone
outranks a held membership and drops it; and the `concord/list` id is not read as
a community subscription. Fragment events are built with `store::list_entry` +
`CommunityList::joined` + `build_list_event` and saved straight into a memory
database, so the tests exercise the real seal/parse/merge path without a relay.
### Phase D — publish — DONE
`crates/community/src/sync.rs`, `crates/concord/src/store.rs`,
`crates/concord/src/cords/cord02/list.rs`
1. `create` mints the genesis, folds it into a state, and saves that state locally
as before, then announces the community: the genesis wraps to its relay set,
and the membership to the account's own List. Both publishes are best-effort —
a relay that is down is a warning, not a failed create.
2. The List write is a read-modify-write over the copy already held (§8). `create`
reads the newest held fragment, unions its own entry in with
`CommunityList::joined`, builds fragment 0, and publishes it. Publishing saves
it locally as a side effect of `send_event`, before any relay is resolved, so
the fragment survives a relay that is down and no explicit database write is
needed.
3. The fragment's `created_at` is `max(now, previous + 1)`, so an addressable
relay can never quietly keep the copy the write meant to replace.
**As built, deviating from the sketch above.** Three decisions the sketch did not
cover:
- The List goes to the account's **NIP-65 write relays** (`.to_nip65()`), not the
community's metadata relays. The List is the member's own document, and it is
the same relay set `subscribe_list` resolves for its `author` filter — the two
halves must agree or a write can land where nothing reads. The genesis wraps,
which belong to the community and not the member, do go to the metadata relays.
- The entry is built by a new `store::list_entry(state, name)`. `JoinMaterial`'
`extra` field is crate-private, so the community crate cannot build one; `name`
is passed in because the state does not carry it — the name lives in the Control
fold, and a created community has it in the metadata.
- A List that already spans more than one fragment is **left alone**: placing a
new membership needs a repack (which fragment does it belong in?), and §8 allows
a repack only against the complete List. `load` keeps a membership the List
never mentions, so the community is still tracked locally; the remote write is
deferred with a warning rather than performed wrongly.
Tests: `create` records a membership the List round-trips, and a second create
unions into the same document instead of replacing it.
### Phase E — verify live
`RUST_LOG=info cargo run -p coop`, sign in with the accordion account that
already belongs to communities. Expect `community {id}: subscribing to ..` and
rows in the sidebar. This is the first time the path can be exercised at all.
## Validation per phase
- `cargo test -p concord` (A, B), `cargo test -p community` (B, C, D).
- `cargo clippy --workspace --all-targets`, `cargo fmt --all -- --check`.
- A is provable against the spec's worked example, so it needs no relay.
- C is provable with `nostr-memory`: fragments are built with `build_list_event`
and saved as the subscription would have, then `load` reads them. No relay,
no `LocalRelay`.
- E is the only step that needs real relays.
## Risks and open decisions
- **Base64url is case-significant and coop's ids are hex everywhere else.**
Confine the codec to `cord02::list`; any normalisation that case-folds will
silently corrupt §8 values. **Resolved in Phase A**: the codec is private to
`list.rs` and never case-folds.
- **`MAX_MEMBERSHIPS = 50` is not in the spec.** §8 has no membership limit; its
only bound is the 65,536-byte *encoded event*. `fits()` still measures the
NIP-44 plaintext, which understates that by roughly a third. Phase D kept the
count cap and added a guard: a List that already spans more than one fragment is
not appended to, because placing a new membership needs a repack. So a member
with more than one fragment gets no remote write until fragmentation lands; the
community stays local and visible.
- **Relay selection is the difference between finding the account's List and
not.** Resolved in Phase C by `ReqTarget::auto`, whose gossip path resolves the
filter's author to their NIP-65 write relays and connects them. A List
published only to relays with no NIP-65 entry is still unreachable; that is a
user-visible relay setting if it ever bites.
- **Private channels stay unsubscribed until `planes()` derives their address
from the granted key** (Phase B gave `ChannelKeyRef` a home for it, but the
discovery fix does not need it). Public discovery works regardless.
- **Two writers, one key.** Once coop publishes `33302`, an account used from
both accordion and coop has both clients writing the List. §8's
read-modify-write is what keeps that from losing memberships — it is not
optional.
- **A create racing the first list sync can publish over an unseen List.**
`record_membership` unions into what the local database holds, and on a fresh
sign-in that is empty until the `concord/list` subscription has delivered. A
create in that window writes a one-entry fragment 0, and an addressable relay
then replaces the account's fuller List with it. The window is the ordinary
sign-in-to-create interval, so it is small but not zero. The honest fix is to
treat the List write as part of the sync loop — republish `list local
memberships` whenever the subscription settles — rather than doing it inside
`create`; an EOSE flag is not enough on its own, because an account with no
NIP-65 relays never reaches EOSE and would then never write at all.
- **`store::save_state` signs with a per-process random key.** Harmless while it
stays local, but it means the state document can never be published or
compared; if a future phase wants it on the wire, it needs the account signer.
- **The deployed reference client still writes the retired kind `13302`.** The
spec this plan implements (`concord-protocol/concord` `main`) moved the List to
`33302` in PR #18, merged **2026-08-15**. The `applesauce` `concord` branch that
accordion.chat builds against still declares `13302`, single-event, capped at 50
memberships, at its head of **2026-08-05**; accordion's pin predates even that
(`0.0.0-concord-20260804145327`). So an account whose memberships were written
by that build stores them under a kind coop deliberately does not read, and will
show an empty sidebar until the client is updated to the fragmented kind. This
is not a bug in the discovery path — Phases C and D are correct against the
current spec — but it is the first thing to check if a live sign-in still shows
nothing. Supporting `13302` alongside `33302` is a deliberate non-goal until the
reference client moves.
+358
View File
@@ -0,0 +1,358 @@
# Concord backend audit and simplification plan
Audit of `crates/concord`, triggered by `CommunityRegistry` never reaching
`subscribe`: `sync::load` found zero community state documents. Tracing that
surfaced two separate things: the app only uses a fraction of the crate, and the
crate's writers take a concrete `nostr::Keys`, which the app's signer can never
produce.
Sizes: ~10,500 lines total — ~6,750 production, ~3,750 tests.
## Decisions taken
- **D1 — Keep the unwired protocol surface.** `cord05`/`cord06`/`pins`/paging
stay in the tree for future use. No mass deletion. (Findings are recorded in
§4 for reference only.)
- **D2 — Replace `&Keys` with a signer boundary** for account-key operations.
Verified feasible against the pinned SDK; design in §2.
---
## 1. `&Keys` cannot be replaced by a public key — but it can be replaced by a signer
The original question was whether functions like `genesis` only need
`signer.get_public_key_async()`. They do not: they sign.
- `cord02::genesis` (`cords/cord02/mod.rs:117`) → `seal_edition` (`:711`) →
`build_seal` (`cord01.rs:217`), which signs the seal (`.finalize(author)`,
`cord01.rs:226`), and `wrap_seal_with` (`:247`), which signs the wrap.
- Self-addressed documents use NIP-44 to self: `seal_to_self`
(`cord01.rs:201`) derives a conversation key from `keys.secret_key()`.
A public key can produce neither a Schnorr signature nor an ECDH key, so
"public-key-only" is impossible. The real defect is the **concrete type**: the
app holds `state::UniversalSigner` (async, possibly NIP-46), and a `nostr::Keys`
can never be conjured from it. `docs/concord-usage.md:535-536` already records
this as a deliberate migration pass.
### What the pinned SDK actually provides
Pinned rev `b230cec` (`nostr` 0.45.4 / `nostr-sdk` 0.45.2):
- There is **no `NostrSigner` trait in this revision.** The async signer surface
is three traits, all in the `nostr` crate:
- `AsyncGetPublicKey``nostr/src/key/public_key.rs:39`
- `AsyncSignEvent``nostr/src/event/mod.rs:366`
- `AsyncNip44``nostr/src/nips/nip44/traits.rs:30`
- `Keys` implements all three (`nostr/src/key/mod.rs:298,309,342`), so tests and
local key holders keep working.
- `UniversalSigner` already implements all three with
`Error = UniversalSignerError` (`crates/state/src/signer.rs:148-191`).
- SDK helpers accept them:
- `EventBuilder::finalize_async``S: AsyncGetPublicKey + AsyncSignEvent + ?Sized`
(`nostr/src/event/builder.rs:171-193`)
- `GiftWrapBuilder::finalize_async``S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44`
(`nostr/src/nips/nip59.rs:334-355`)
- `UnwrappedGift::from_gift_wrap_async``T: AsyncNip44` (`nip59.rs:84-90`)
So the answer is yes: pass a signer. `UniversalSigner` works as-is.
### Per-function bounds, not a bundle
Each function should request only the capabilities it uses. The SDK itself is
designed this way (`UnsignedEvent::finalize_async` takes only `AsyncSignEvent`,
`EventBuilder::finalize_async` takes `AsyncGetPublicKey + AsyncSignEvent`,
NIP-59 takes all three).
| Operation | Bounds |
| --- | --- |
| Sign a seal/edition/rekey wrap, author already known | `AsyncSignEvent` |
| Build an event where the author comes from the signer | `AsyncGetPublicKey + AsyncSignEvent` |
| To-self documents (Community List, Invite List) | `AsyncGetPublicKey + AsyncNip44`, plus `AsyncSignEvent` when the document is itself an event |
| Decrypt-only (`parse_list_event`, `unwrap_direct_invite`) | `AsyncNip44` |
| Rekey blob encrypt (`build_blob`) | `AsyncGetPublicKey + AsyncNip44` (no signing) |
| Rekey blob open (`open_blob`) | `AsyncNip44` |
| Direct invite build (`GiftWrapBuilder`) | all three |
Use generics (`S: AsyncSignEvent + ?Sized`), never `&dyn`: the traits carry
associated `Error` types, so `dyn AsyncSignEvent` would force the concrete error
at every call site (`dyn AsyncSignEvent<Error = UniversalSignerError>`),
defeating the abstraction. The SDK uses generics throughout for this reason.
Do **not** define a supertrait bundle
`trait Signer: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 {}`: all three
supertraits declare an associated `Error`, so `Self::Error` becomes ambiguous,
and the bundle forces NIP-44 onto purely-signing callers (and vice versa).
Inside concord, replace `builder.finalize(&keys)` with
`builder.finalize_async(signer).await`. `finalize_async` fetches the signer's
public key and uses it as the event author, exactly as `finalize` did, so the
bytes are unchanged for every caller that passes a matching signer.
We deliberately **do not** pre-check the author against `rumor.pubkey` inside
`build_seal`. The seal's author is the signer's own public key, matching the old
`finalize` semantics; a signer that does not match the rumor is still caught by
`open_wrap_at` as `AuthorMismatch` (`cord01.rs:328`). Pre-checking would also
make it impossible to construct the hostile seals the cord suite relies on as
test vectors (`cord01.rs` `hostile_wraps_are_dropped_in_order`).
If the repeated `<S as ...>::Error: Error + Send + Sync + 'static` bounds
become too noisy, the only stable-Rust way to shorten them is an owned
error-erased trait (as the app already does with
`crates/state/src/signer.rs:64-138`). That trades precision for brevity; keep
per-function bounds unless the noise proves unmanageable.
### What must NOT go through the signer
- **Group-key NIP-44.** `cord01::{seal_bytes, open_bytes, wrap_seal,
wrap_seal_with, rewrap_seal}` encrypt under a `ConversationKey` derived from
HKDF group secrets. `AsyncNip44` can only ECDH against a public key, so group
encryption stays on `ConversationKey` / `GroupKey::keys()`.
- **Wrap signatures.** Wraps are signed by the derived group signer key
(`GroupKey::keys()`), not the account.
- **Locally held raw secrets.** `cord05::{build_bundle_event, build_revocation}`
take a generated `link_signer` whose secret the app stores as
`signer_sk` (`docs/concord-usage.md:306-321`). `&Keys` is correct there; the
app has the secret itself.
- **Local database artifacts.** `store::{cache_rumor, save_state}` sign with the
internal random `LOCAL_KEYS` (`store.rs:18`). No user signer involved.
### Call-site inventory
Account-key sites to migrate:
| Site | Today | After | Bounds |
| --- | --- | --- | --- |
| `cord02::genesis` (`cord02/mod.rs:117`) | `owner: &Keys` | `owner: &S` | `AsyncGetPublicKey + AsyncSignEvent` |
| `ControlWriter::{publish, set_*}` (`cord02/mod.rs:214-425`) | `keys: &Keys` | `keys: &S` | `AsyncGetPublicKey + AsyncSignEvent` |
| `seal_edition` (`cord02/mod.rs:711`, internal) | `owner: &Keys` | `owner: &S` | `AsyncGetPublicKey + AsyncSignEvent` |
| `cord01::build_seal` (`cord01.rs:217`) | `author: &Keys` | `author: &S` | `AsyncGetPublicKey + AsyncSignEvent` |
| `cord01::{seal_to_self, open_to_self}` (`:201,209`) | `keys: &Keys` | `&S`, async | `AsyncGetPublicKey + AsyncNip44` |
| `guestbook::seal_rumor` (`guestbook.rs:186`) | `author: &Keys` | `author: &S` | `AsyncGetPublicKey + AsyncSignEvent` |
| `cord03::seal_rumor` (`cord03.rs:295`) | `author: &Keys` | `author: &S` | `AsyncGetPublicKey + AsyncSignEvent` |
| `list::build_list_event` (`list.rs:186`) | `keys: &Keys` | `keys: &S` | all three |
| `list::parse_list_event` (`list.rs:197`) | `keys: &Keys` | `keys: &S` | `AsyncGetPublicKey + AsyncNip44` |
| `cord05::{build_direct_invite, unwrap_direct_invite}` (`:451,478`) | `inviter`/`recipient: &Keys` | **done** | build: all three (`Sized`); unwrap: `AsyncNip44` (`Sized`) |
| `cord05::{build_invite_list, parse_invite_list}` (`:593,604`) | `keys: &Keys` | **done** | build: all three; parse: `AsyncGetPublicKey + AsyncNip44` |
| `cord06::build_blob` (`:302`) | `rotator: &Keys` | **done** | `AsyncGetPublicKey + AsyncNip44` |
| `cord06::open_blob` (`:319`) | `recipient: &Keys` | **done** | `AsyncNip44` |
| `cord06::{build_rekey_chunks, seal_dissolved}` (`:602,737`) | actor `&Keys` | **done** | `AsyncGetPublicKey + AsyncSignEvent` |
Leave unchanged: `cord05::{build_bundle_event, build_revocation}`, all
`cord01` wrap functions, `GroupKey::keys()`, `store::LOCAL_KEYS`.
### Async ripple and tests
Every migrated function becomes `async`. `smol` is already a dev-dependency of
concord (`crates/concord/Cargo.toml:21-23`), so affected `#[test]`s become
`smol::block_on(...)` wrappers. The app's call sites are already async
background tasks.
### Known constraints
- `GiftWrapBuilder::finalize_async` and `UnwrappedGift::from_gift_wrap_async`
are generic over `S: Sized` (no `?Sized`), so those functions must stay
generic, never `&dyn`.
- Every converted `S::Error` must be `Error + Send + Sync + 'static` for the
SDK helpers' `Error::other` (`nostr/src/error.rs:100-105`) and for
`anyhow`; `Keys::AsyncGetPublicKey::Error = Infallible`,
`Keys::AsyncSignEvent::Error = nostr::Error`, `UniversalSignerError`
(`crates/state/src/signer.rs:10-32`) all qualify.
- `AsyncGetPublicKey` is worth requiring alongside `AsyncSignEvent` wherever the
author is embedded in the payload: `sign_event` signs the id of the given
unsigned event without rewriting its pubkey, so a mismatched signer is only
caught later by signature verification.
---
## 2. Migration plan
### Phase 1 — replace `&Keys` with per-function signer bounds (no behavior change) — DONE
1. No new module: change the signatures listed in the inventory table to
generics over the SDK traits (`S: AsyncGetPublicKey + AsyncSignEvent`,
`S: AsyncSignEvent`, `S: AsyncGetPublicKey + AsyncNip44`, or `S: AsyncNip44`).
2. Migrate the live path only: `cord01::build_seal`, `cord01::{seal_to_self,
open_to_self}`, `seal_edition`, `genesis`, `ControlWriter`, `guestbook::
seal_rumor`, `cord03::seal_rumor`, `list::{build,parse}_list_event`.
3. Update `docs/concord-usage.md` examples to take a signer.
4. Update concord tests to `smol::block_on`; `&Keys` keeps working because it
implements all three traits.
Validation: `cargo test -p concord` — 46 passed, 0 failed. The one behavior
change from the plan sketch is the dropped up-front author check in §1.
`cord01::{seal_to_self, open_to_self}` now take `&str` and return `String`
(NIP-44 is UTF-8 text), so the `list` and invite-list callers read the plaintext
with `serde_json::from_str`.
**Unplanned but forced:** `cord01::{build_seal, seal_to_self, open_to_self}` are
shared helpers, so the unwired callers had to be migrated in the same pass to
keep the crate compiling: `cord05::{build_invite_list, parse_invite_list}` and
`cord06::{build_rekey_chunks, seal_dissolved}` (Phase 3's mechanical part).
`cord05::{build_direct_invite, unwrap_direct_invite}` and
`cord06::{build_blob, open_blob}` were untouched by Phase 1 — they use the NIP-59
and group-key paths, not the migrated helpers — and were migrated in Phase 3.
### Phase 2 — app uses the signer — DONE
1. `sync::create(client, signer, metadata)` (`crates/community/src/sync.rs`) runs
`cord02::genesis`, opens the genesis editions, persists the state with
`store::save_state`, and also stores the genesis wraps so the control plane
folds locally. It is generic over `S: AsyncGetPublicKey + AsyncSignEvent + ?Sized`
(the bounds `genesis` needs and no more, per D2); the app passes its
`UniversalSigner`, so no secret material is exposed and NIP-46 accounts work
too.
`CommunityRegistry::create(metadata, cx)` (`crates/community/src/lib.rs`) is
the GPUI wrapper: it refuses when no account is signed in, otherwise runs the
task off-thread and refreshes tracking, so `sync::load` now returns one state
and `subscribe` finally fires.
2. The app-side reimplementation of `list::parse_list_event`
(`crates/community/src/sync.rs:154-156`) is deleted; `load_list` calls the
real `cord02::list::parse_list_event`.
3. Relays in the metadata are persisted but the genesis is **not** published yet;
`create` is local-only. Wiring genesis/broadcast through the relay pool is the
next app step, not part of this phase.
Validation: `cargo test -p community` (1 passed), `cargo test -p concord`
(46 passed), `cargo clippy -p community --all-targets`, `cargo fmt -p community
--check`, and `cargo check --workspace --all-targets` are all clean.
**Deviation from the plan sketch:** the planned "drive `CommunityRegistry`" test
is instead a `sync`-layer test, `sync::tests::
creating_a_community_persists_a_state_that_subscribes_and_folds`. A GPUI-level
test cannot construct a `NostrRegistry` — it opens LMDB at `config_dir()` and
connects bootstrap relays in `NostrRegistry::new`, which is private and not
injectable — so the test drives a `Client` on an in-memory database
(`nostr-memory`, already a dev-dependency) directly. It asserts the whole
contract the registry depends on: `create` persists a state `load` returns, the
subscription filter addresses the genesis wraps, `fold` yields the created
community, and an inbound control edit folds over it.
### Phase 3 — migrate the remaining unwired writers — DONE
`cord05::{build_direct_invite, unwrap_direct_invite}` and
`cord06::{build_blob, open_blob}` now take a signer. The NIP-59 pair keeps a
`Sized` `S` (`AsyncGetPublicKey + AsyncSignEvent + AsyncNip44` to build,
`AsyncNip44` to unwrap) because the SDK's `GiftWrapBuilder::finalize_async` and
`UnwrappedGift::from_gift_wrap_async` are `Sized`-bounded. The blob pair is
`AsyncGetPublicKey + AsyncNip44` to build and `AsyncNip44` to open, with `?Sized`.
The blobs forced one behavior change, because a signer's NIP-44 is text-only
(`nip44_encrypt_async(public_key, &str)`) while the blob plaintext is a
fixed-width binary record. `build_blob` now carries that record base64-encoded
inside the NIP-44 envelope and `open_blob` decodes it again. The record layout,
the `locator`, and the envelope are unchanged; only the bytes inside the envelope
differ. There are no golden vectors for blobs and no producer or consumer other
than these two functions, so the round-trip stays self-consistent; cord06 remains
unwired and persists nothing.
Validation: `cargo test -p concord` — 46 passed, 0 failed (the 80-blob
`a_full_send_chunk_stays_within_a_relay_event` size assertion still holds under
the base64 record). `cargo clippy -p concord --all-targets` and
`cargo fmt -p concord --check` are clean.
### Phase 4 — duplication and hygiene (independent, low risk) — DONE
1. DONE — `store::load_states(client)` added (with a direct `store` test), the
app-side state-document scan in `sync::load` is gone.
2. DONE — `store::STATE_PREFIX` is public; the app-side `concord/` literals are
gone, and subscription ids reuse the exported prefix.
3. DONE — the shared rumor tag readers and error live in a new `cords::rumor`
module (`RumorError`, `tag`, `required`, `value`, `pubkey`,
`optional_citation`), re-exported as `cord03::ChatError` and
`cord02::guestbook::GuestbookError`. `cord06` keeps its own narrower
`RekeyError`, which the plan scoped out.
4. RETAINED — none of the "never-varied parameters" were removed. Each is
load-bearing for a flow the fold or a writer already implements (D1):
- `complete_memberlist`'s `banned_at` is read by the fold and is exercised
with a non-empty map by `join_leave_kick_and_snapshot_converge_to_one_memberlist`;
`docs/concord-usage.md` already promises to fill it once the banlist head's
timestamp is plumbed through.
- `cache_rumor -> Result<bool>` is read by `backfill` to drop expired rumors.
- `coalesce`'s `snapshot_authority` gates which snapshots apply; passing
`None` today is a policy, not a dead parameter.
- `seal_rumor(ephemeral)` and the `until` cursors on `backfill`/`query_rumors`
select protocol modes and paging.
5. DONE — tightened `cord04` visibility: `edition_hash`, `fold`, `FoldResult`,
`bootstrap_head`, `parse_banlist`, `Role::parse` and `Grant::parse` are no
longer `pub`. `HeadSelection` stays `pub` because the public `fold_head`
returns it.
6. DONE — doc drift fixed: the store takes `&Client` throughout (including
`load_state`/`load_states`/`query_rumors`, not just the writers), `backfill`
arity, `set_pin_list`'s missing `.await`, the GPUI `init` signature and
registry names, and the "Not wired up yet" registry bullet.
### Phase 5 — sidebar calls `create` — DONE
The last blocker was that nothing invoked `CommunityRegistry::create`; the
running app logged `community load: 0 state document(s) found` and `subscribe`
never ran. The sidebar now:
1. Renders `CommunityRegistry::communities()` instead of the hardcoded
`dummy_communities()`. `SidebarRow::Community` carries an `Entity<Community>`,
labelled with `Community::name()` (control-fold metadata, falling back to the
community id until the first fold).
2. Adds a "New community" row to the Community section that opens a name prompt
and calls `CommunityRegistry::create` with default metadata. Relays stay empty,
so the subscription resolves through `ReqTarget::auto` against the pool's
relays rather than a manual target that `add_relay` might not have connected.
3. Observes the registry, so a `track` or fold re-render reaches the list, and
subscribes to `CommunityEvent::Error`, which is now logged
(`log::error!("community: {error}")`) instead of vanishing. A `cx.notify()` in
the registry's per-community observer propagates the fold that fills in the
name.
Validation: `cargo check -p workspace -p community --all-targets`,
`cargo test -p community` (1 passed), `cargo clippy -p workspace -p community
--all-targets`, and `cargo fmt -p workspace -p community --check` are clean.
Still local-only: the genesis is persisted but not published to relays, so a
second account cannot discover the community yet.
---
## 3. Retained-by-decision surface (reference only)
Per D1 these stay, but they should be understood as unwired, not live:
| Module | Approx. prod LOC | App use |
| --- | --- | --- |
| `cord06` rotation/refounding/dissolution | ~850 | none |
| `cord05` invites/links/direct/list | ~650 | none (types only, via unused `list::join_material`) |
| `cord04::pins` | ~550 | none |
| `cord03` write path + `fold` + `plane_keys` | ~340 | only `open` / `expiration_of` |
| guestbook / list write paths | ~240 | `open`, `coalesce`, `complete_memberlist`, `is_live` |
| `store` paging / purge / query / load_state(s) | ~180 | `cache_rumor`, `save_state`, `load_states` |
Truly unreferenced even by tests (safe candidates, but kept per D1):
`CommunityInvite::expired`, `GroupKey::pk_hex`, `From<[u8; 32]>` impls,
`CommunityRoles::{roles, is_empty}`.
---
## 4. Non-goals
- No mass deletion of unwired modules (D1).
- No changes to frozen HKDF derivations, locators, golden vectors, or `cord01`
envelope semantics. The one exception Phase 3 forced is the blob plaintext
encoding (base64 inside the envelope, see Phase 3); the blob record layout and
`locator` are untouched.
- No group-key encryption through the signer.
- Tests move only alongside the code they cover.
## 5. Validation
- `cargo test -p concord` after each phase; `cargo test --workspace` before
landing.
- Phase 1 is behavior-preserving: the existing cord test suite is the oracle.
- Phase 2 adds the app-level test: seed a `CommunityState` via
`store::save_state`, drive `CommunityRegistry`, assert a subscription is made
and an inbound wrap folds into the community.
- Phase 4: `cargo test -p concord -p community` (47 + 1 passed),
`cargo clippy -p concord -p community --all-targets`, and
`cargo fmt -p concord -p community --check` are all clean.
## 6. Immediate unblock
Option 2 (the clean path, using `UniversalSigner`) landed in Phase 2. Option 1
(exposing the local `Keys` from `crates/state/src/lib.rs:254`) is obsolete.
+150 -55
View File
@@ -47,7 +47,7 @@ use concord::cord02::{self, CommunityMetadata};
use concord::store::{self, CommunityState, save_state}; use concord::store::{self, CommunityState, save_state};
let metadata = CommunityMetadata { name: "Room".into(), ..Default::default() }; let metadata = CommunityMetadata { name: "Room".into(), ..Default::default() };
let minted = cord02::genesis(&owner_keys, &metadata, now_secs)?; let minted = cord02::genesis(&owner_keys, &metadata, now_secs).await?;
// minted.identity — community_id, owner, owner_salt (verify() recomputes it) // minted.identity — community_id, owner, owner_salt (verify() recomputes it)
// minted.wraps — the two owner-signed genesis editions, already sealed // minted.wraps — the two owner-signed genesis editions, already sealed
@@ -73,12 +73,18 @@ let editions: Vec<ParsedEdition> = minted
.collect::<Result<_, _>>()?; .collect::<Result<_, _>>()?;
let mut state = CommunityState::from_genesis(&minted, &editions, added_at_ms)?; let mut state = CommunityState::from_genesis(&minted, &editions, added_at_ms)?;
save_state(database, &state).await?; save_state(&client, &state).await?;
``` ```
Put the community's relay list into `state.relays` and add those relays to the Put the community's relay list into `state.relays` and add those relays to the
client explicitly — coop's client is a gossip client with no background refresh. client explicitly — coop's client is a gossip client with no background refresh.
Creating is not finished until the membership is announced. The two writes are
independent and both best-effort: the genesis wraps go to the community's
relays, and the membership goes to the account's own Community List (below), so a
new device — or another client — can find the community without an invite.
`crates/community`'s `sync::create` performs both.
## Joining ## Joining
An invite link resolves to a bundle: An invite link resolves to a bundle:
@@ -98,7 +104,7 @@ let invite = match cord05::parse_bundle_event(&event, &link.link_signer, &invite
A Direct Invite arrives as a NIP-59 gift wrap addressed to the member: A Direct Invite arrives as a NIP-59 gift wrap addressed to the member:
```rust ```rust
let (inviter, invite) = cord05::unwrap_direct_invite(&wrap, &my_keys)?; let (inviter, invite) = cord05::unwrap_direct_invite(&wrap, &my_keys).await?;
``` ```
Either way the invite carries `community_id`, `owner`, `owner_salt`, Either way the invite carries `community_id`, `owner`, `owner_salt`,
@@ -114,7 +120,7 @@ use concord::cord02::guestbook;
let guestbook = guestbook_group_key(&invite.community_root, &invite.community_id, invite.root_epoch)?; let guestbook = guestbook_group_key(&invite.community_root, &invite.community_id, invite.root_epoch)?;
let rumor = cord02::guestbook::build_join(my_pk, Some((creator_npub, label)), now_ms); let rumor = cord02::guestbook::build_join(my_pk, Some((creator_npub, label)), now_ms);
let (wrap, _) = cord02::guestbook::seal_rumor(&rumor, &guestbook, &my_keys)?; let (wrap, _) = cord02::guestbook::seal_rumor(&rumor, &guestbook, &my_keys).await?;
client.send_event(&wrap).to(&relays).await?; client.send_event(&wrap).to(&relays).await?;
``` ```
@@ -159,7 +165,7 @@ use concord::derive::channel_group_key;
let plane = channel_group_key(&community_root, &channel, epoch)?; // public channel let plane = channel_group_key(&community_root, &channel, epoch)?; // public channel
let rumor = build_message(my_pk, &channel, epoch, text, None, at_ms, timer); let rumor = build_message(my_pk, &channel, epoch, text, None, at_ms, timer);
let (wrap, wrap_key) = cord03::seal_rumor(&rumor, &plane, &my_keys, false)?; let (wrap, wrap_key) = cord03::seal_rumor(&rumor, &plane, &my_keys, false).await?;
client.send_event(&wrap).to(&relays).await?; client.send_event(&wrap).to(&relays).await?;
``` ```
@@ -192,7 +198,7 @@ for wrap in &wraps {
let Ok((opened, rumor)) = cord03::open(wrap, group, &channel, *epoch) else { let Ok((opened, rumor)) = cord03::open(wrap, group, &channel, *epoch) else {
continue; continue;
}; };
store::cache_rumor(database, &channel, &opened).await?; store::cache_rumor(&client, &channel, &opened).await?;
rumors.push(rumor); rumors.push(rumor);
} }
@@ -209,13 +215,13 @@ let messages = fold(&rumors, Timestamp::now(), |actor, citation, author| {
Relay history pages through the local cache: Relay history pages through the local cache:
```rust ```rust
let page = store::backfill(client, database, &channel, &held, until, 50).await?; let page = store::backfill(client, &channel, &held, until, 50).await?;
let cached = store::query_rumors(database, &channel, None, 50).await?; let cached = store::query_rumors(&client, &channel, None, 50).await?;
``` ```
`backfill` walks newest-first across every held epoch, caches what it opens, and `backfill` walks newest-first across every held epoch, caches what it opens, and
stops on a short page. `query_rumors` is the read path when the group keys are stops on a short page. `query_rumors` is the read path when the group keys are
gone. Run `store::purge_expired(database, &channel, now)` on the same cadence as gone. Run `store::purge_expired(client, &channel, now)` on the same cadence as
any other local sweep — the timer is cooperative, so the local store is the any other local sweep — the timer is cooperative, so the local store is the
artifact that has to forget. artifact that has to forget.
@@ -253,7 +259,7 @@ let writer = ControlWriter { author: my_pk, read: read.clone(), signer: signer.c
let head = control.floors.get(entity).cloned(); let head = control.floors.get(entity).cloned();
let (wrap, new_head) = writer.set_community_metadata( let (wrap, new_head) = writer.set_community_metadata(
&my_keys, &community_id, &metadata, head.as_ref(), citation, now_secs)?; &my_keys, &community_id, &metadata, head.as_ref(), citation, now_secs).await?;
``` ```
`citation` is the `vac` the actor acts under — `None` only for the owner. Build it `citation` is the `vac` the actor acts under — `None` only for the owner. Build it
@@ -275,7 +281,7 @@ let head_content = control.pin_content(&community_id, &channel).unwrap_or("");
let read = cord04::pins::read_list(head_content, |epoch| channel_group_key(&root, &channel, epoch).ok()); let read = cord04::pins::read_list(head_content, |epoch| channel_group_key(&root, &channel, epoch).ok());
let content = cord04::pins::publishable(&read, channel_is_private, &plane, epoch)?; let content = cord04::pins::publishable(&read, channel_is_private, &plane, epoch)?;
let (wrap, _) = writer.set_pin_list( let (wrap, _) = writer.set_pin_list(
&my_keys, &community_id, &channel, &content, head, citation, now_secs)?; &my_keys, &community_id, &channel, &content, head, citation, now_secs).await?;
``` ```
Reading is verification: `read_list` decodes either content form (public, or Reading is verification: `read_list` decodes either content form (public, or
@@ -308,7 +314,7 @@ keep it against the token in the member's own Invite List — a local document
encrypted to self, exactly like the Community List: encrypted to self, exactly like the Community List:
```rust ```rust
let mut list = cord05::parse_invite_list(&my_keys, &event)?; let mut list = cord05::parse_invite_list(&my_keys, &event).await?;
list.entries.push(InviteEntry { list.entries.push(InviteEntry {
token: HEXLOWER.encode(&token), token: HEXLOWER.encode(&token),
signer_sk: link_signer.secret_key().to_secret_hex(), signer_sk: link_signer.secret_key().to_secret_hex(),
@@ -319,7 +325,7 @@ list.entries.push(InviteEntry {
expires_at: None, expires_at: None,
extra: Default::default(), extra: Default::default(),
}); });
let event = cord05::build_invite_list(&my_keys, &list)?; // kind 13303 let event = cord05::build_invite_list(&my_keys, &list).await?; // kind 13303
// Retiring is a tombstone, never a deletion: it beats a stale copy terminally. // Retiring is a tombstone, never a deletion: it beats a stale copy terminally.
list.tombstones.push(InviteTombstone { list.tombstones.push(InviteTombstone {
@@ -356,12 +362,16 @@ let (control_pk, control_root) = match scope {
RekeyScope::Channel(_) => (None, None), RekeyScope::Channel(_) => (None, None),
}; };
let blobs = members let mut blobs = Vec::with_capacity(members.len());
.iter()
.map(|member| { for member in &members {
cord06::build_blob(&my_keys, member, scope, plan.epoch, &new_key, control_pk.as_ref(), control_root) blobs.push(
}) cord06::build_blob(
.collect::<Result<Vec<_>, _>>()?; &my_keys, member, scope, plan.epoch, &new_key, control_pk.as_ref(), control_root,
)
.await?,
);
}
let rekey_group = cord06::rekey_group(scope, &community_root, &community_id, plan.epoch)?; let rekey_group = cord06::rekey_group(scope, &community_root, &community_id, plan.epoch)?;
let wraps = cord06::build_rekey_chunks( let wraps = cord06::build_rekey_chunks(
@@ -375,7 +385,8 @@ let wraps = cord06::build_rekey_chunks(
citation, citation,
false, false,
now_secs, now_secs,
)?; )
.await?;
``` ```
On the receiving side, `cord06::parse_rekey_chunk(&opened)` per wrap, then On the receiving side, `cord06::parse_rekey_chunk(&opened)` per wrap, then
@@ -385,11 +396,15 @@ member finds their delivery with `find_my_blobs` / `open_blob`, and adopts the k
only if the plaintext binds to the scope and epoch they expect and its `prevcommit` only if the plaintext binds to the scope and epoch they expect and its `prevcommit`
matches the key they already hold. Two concurrent rotations settle on `fork_winner`. matches the key they already hold. Two concurrent rotations settle on `fork_winner`.
The blob plaintext is a fixed-width binary record, but a signer's NIP-44 is
text-only, so `build_blob` carries it base64-encoded inside the envelope.
`open_blob` mirrors that, so the record layout and the `locator` are unchanged.
Dissolution is owner-only and terminal: Dissolution is owner-only and terminal:
```rust ```rust
let rumor = cord06::dissolved_tombstone_rumor(owner_pk, &community_id, now_secs); let rumor = cord06::dissolved_tombstone_rumor(owner_pk, &community_id, now_secs);
let wrap = cord06::seal_dissolved(&rumor, &community_id, &my_keys, now_secs)?; let wrap = cord06::seal_dissolved(&rumor, &community_id, &my_keys, now_secs).await?;
// A receiver seals the community read-only on sight. // A receiver seals the community read-only on sight.
if cord06::verify_dissolved(&wrap, &identity) { if cord06::verify_dissolved(&wrap, &identity) {
@@ -404,22 +419,77 @@ A member's own memberships, synced across their devices:
```rust ```rust
use concord::cord02::list; use concord::cord02::list;
let material = cord02::list::join_material(&invite, staff.then_some(&control_root)); let entry = concord::store::list_entry(&state, &metadata.name); // state → §8 material
let mut mine = cord02::list::parse_list_event(&my_keys, &event)?; let held = list::parse_list_event(&my_keys, &event).await?; // validates the d tag
mine = cord02::list::merge(mine, cord02::list::CommunityList { let mine = held.joined(entry); // community_id-keyed union
entries: vec![cord02::list::CommunityListEntry { community_id, seed: material.clone(), current: material, added_at: now_ms, extra: Default::default() }], let event = list::build_list_event(&my_keys, &mine, 0, now_secs).await?; // kind 33302, d = 0
..Default::default() client.send_event(&event).to_nip65().await?; // account's own write relays
});
let event = cord02::list::build_list_event(&my_keys, &mine)?; // kind 13302, NIP-44 to self
``` ```
The publish needs no separate database write: `send_event` persists the event
locally *before* it resolves targets, so the fragment is available to the
`concord/list` read path even if every relay is unreachable.
`join_material(&invite, control_root)` makes the §8 material from a CORD-05
invite; `store::list_entry(state, name)` makes it from a `CommunityState`, and is
what a write path uses after a create, join or rename. `joined` and `tombstoned`
are the two mutations: both are `community_id`-keyed unions, so neither an append
nor a leave can lose a membership the other writer has.
The entry is signed by the member's real key and sealed to that same key
(`seal_to_self`), so only the member's devices read it — a stranger's
`parse_list_event` fails rather than returning a partial list. Publishing goes to
the member's **NIP-65 write relays**, the same set the `concord/list`
subscription resolves for its `author` filter.
Kind `33302` is **addressable and fragmented**: one event per fragment, its `d`
tag the fragment index in decimal. `frags` in the payload declares how many the
List has, and `is_complete(held_indices)` answers whether the client has a
fragment at every index below it. `merge` resolves a `frags` disagreement to the
larger value. (`13302`, the single-event List, is retired by the spec — a
replaceable kind cannot fragment.)
The payload's 32-byte values are **unpadded base64url at every depth**, which is
section-scoped to §8: CORD-05 invites stay hex. The writer re-encodes them on
every serialization, so its output is always the canonical 43-character spelling;
the reader also accepts non-zero trailing bits, because the spec's own worked
example contains them and no reader can tell a mis-encoded named field from a
correct one. The codec is `utils::base64url` and the wire structs behind the
List's `Serialize`/`Deserialize` are the only callers, so no other encoding path
is touched.
Three write-time rules are folded into serialization, so an in-memory document
and its wire form differ:
- an embedded snapshot omits `community_id` and inherits the entry's;
- `seed` is omitted when it equals `current`, and its cosmetic fields (`name`,
`relays`, each channel's `name`) are overwritten from `current` first, so a
rename collapses the snapshots instead of forking them;
- an entry whose `added_at` does not outrun its tombstone is omitted — the
tombstone alone carries the state.
`is_live(&id)` answers joined-versus-left: a tombstone is terminal until a `is_live(&id)` answers joined-versus-left: a tombstone is terminal until a
strictly newer join outruns it. `fits()` is the write gate 50 memberships and strictly newer join outruns it. `fits()` is the write gate: 50 memberships and
the NIP-44 size cap, both protocol constants. the NIP-44 plaintext cap. The 50 is a stopgap inherited from the retired
single-event design — §8 has **no membership limit**, its only bound is the
65,536-byte encoded event, and the real fix is to start a new fragment on write.
Until that lands, an append onto a List that already spans more than one fragment
is refused rather than performed against a partial read, because placing a new
membership needs a repack. The community stays local (`load` keeps a membership
the List never mentions) and the write is deferred with a warning.
Discovery is a **subscription, not a fetch**: subscribe with
`Filter::new().kind(Kind::Custom(KIND_COMMUNITY_LIST)).author(my_pk)` and read the
fragments back out of `client.database()`. The client persists a relay's event
before it notifies, so a subscription plus a database read loses nothing and
needs no explicit save. Parse each event with `parse_list_event`, keep the newest
per `fragment_index`, and `merge` them — reading an incomplete List is safe, since
a missing fragment is only news not yet heard.
## GPUI integration ## GPUI integration
`crates/concord` stays GPUI-free. The UI layer adds a registry global and one `crates/concord` stays GPUI-free; the registry and sync engine live in
`crates/community`. That layer adds a registry global and one
entity per community, and moves every decrypt, verification, fold and I/O off entity per community, and moves every decrypt, verification, fold and I/O off
the foreground thread. the foreground thread.
@@ -428,13 +498,13 @@ the foreground thread.
Same shape as `ChatRegistry`: Same shape as `ChatRegistry`:
```rust ```rust
pub fn init(window: &mut Window, cx: &mut App) { pub fn init(cx: &mut App) {
ConcordRegistry::set_global(cx.new(|cx| ConcordRegistry::new(window, cx)), cx); CommunityRegistry::set_global(cx.new(CommunityRegistry::new), cx);
} }
impl ConcordRegistry { impl CommunityRegistry {
pub fn global(cx: &App) -> Entity<Self> { pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalConcordRegistry>().0.clone() cx.global::<GlobalCommunityRegistry>().0.clone()
} }
} }
``` ```
@@ -443,8 +513,8 @@ Call it after `cord03::init` in `desktop/src/main.rs` and `web/src/lib.rs`, and
subscribe to `NostrRegistry` for `SignerChanged` so the communities reset with subscribe to `NostrRegistry` for `SignerChanged` so the communities reset with
the account. the account.
- `ConcordRegistry` holds `communities: Vec<Entity<Community>>`, an index by - `CommunityRegistry` holds `communities: Vec<Entity<Community>>`, an index by
`CommunityId`, and `tasks: SmallVec<[Task<Result<(), Error>>; 2]>`. `CommunityId`, and `tasks: SmallVec<[Task<Result<()>>; 2]>`.
- `Community` owns one `CommunityState`, the last `ControlFold`, the member list - `Community` owns one `CommunityState`, the last `ControlFold`, the member list
and the channel list. Views render `Entity<Community>`; no protocol state and the channel list. Views render `Entity<Community>`; no protocol state
lives in a view. lives in a view.
@@ -459,7 +529,7 @@ A background task never touches an entity. It sends results through a bounded
```rust ```rust
let (signal_tx, signal_rx) = flume::bounded::<Signal>(256); let (signal_tx, signal_rx) = flume::bounded::<Signal>(256);
let database = client.database().clone(); let client = client.clone();
// Background: open, verify, fold — no entities. // Background: open, verify, fold — no entities.
self.ingress = Some(cx.background_spawn(async move { self.ingress = Some(cx.background_spawn(async move {
@@ -468,7 +538,7 @@ self.ingress = Some(cx.background_spawn(async move {
continue; continue;
}; };
let (opened, rumor) = cord03::open(wrap, &plane.group, &plane.channel, plane.epoch)?; let (opened, rumor) = cord03::open(wrap, &plane.group, &plane.channel, plane.epoch)?;
store::cache_rumor(database.as_ref(), &plane.channel, &opened).await?; store::cache_rumor(&client, &plane.channel, &opened).await?;
signal_tx.send_async(Signal::Chat { channel: plane.channel, rumor }).await?; signal_tx.send_async(Signal::Chat { channel: plane.channel, rumor }).await?;
} }
Ok(()) Ok(())
@@ -483,8 +553,8 @@ self.consumer = Some(cx.spawn(async move |this, cx| {
})); }));
``` ```
- `client.database()` is a `&Arc<dyn NostrDatabase>` and `store::save_state` - Every store function takes the `&Client` and reaches the database through
wants `&dyn NostrDatabase`, so clone the `Arc` and pass `database.as_ref()`. `client.database()`, so clone the `Client` into the background task.
- Keep long-lived tasks in fields — dropping a `Task` cancels it. Assign `None` - Keep long-lived tasks in fields — dropping a `Task` cancels it. Assign `None`
to an `Option<Task<_>>` before respawning it; a signer change replaces both to an `Option<Task<_>>` before respawning it; a signer change replaces both
the listener and the consumer. the listener and the consumer.
@@ -493,8 +563,9 @@ self.consumer = Some(cx.spawn(async move |this, cx| {
- Do the first load in `cx.defer_in(window, ...)` so `init` returns before the - Do the first load in `cx.defer_in(window, ...)` so `init` returns before the
first relay request. first relay request.
- NIP-46 signing is async: call `signer.get_public_key_async()` / - NIP-46 signing is async: call `signer.get_public_key_async()` /
`sign_event_async` inside the background task. The builders still take `sign_event_async` inside the background task. Every account-key writer takes
`&Keys`, so run them where device keys are available. any signer (`Keys` or the app's `UniversalSigner`) and is `async`, so `await`
it there rather than requiring device keys.
### Subscriptions ### Subscriptions
@@ -527,19 +598,43 @@ client.subscribe(filter).with_id(sub_id).await?;
## Not wired up yet ## Not wired up yet
- **No registry and no sync engine.** `crates/concord` has no subscriptions, no - **`crates/concord` stays protocol-only; the registry lives in
`init`, and no `Entity<Community>`; the UI owns subscribing, routing a wrap to `crates/community`.** `concord` has no subscriptions, no `init`, and no
the plane whose address it carries, and rebuilding a subscription when a plane's `Entity<Community>`; `community::CommunityRegistry` owns one `Entity<Community>`
address changes (join, channel added, rekey folded). GPUI integration above is per state document, subscribes when a community's plane set changes, and
the shape to build, not code that exists. re-folds on an inbound wrap. The sidebar observes the registry, logs
- **Every writer takes `&Keys`, not a `NostrSigner`.** NIP-46 is one deliberate `CommunityEvent::Error` through `log::error!`, and its "New community" row opens
pass over the builders, not a per-call patch. a name prompt that calls `CommunityRegistry::create`. `create` persists the
genesis locally, publishes the wraps to the community's relays, and records the
membership in the account's Community List — all best-effort, so a relay that is
down warns without losing the community. Discovery
subscribes to the account's CORD-02 Community List (`33302`) under the
`concord/list` subscription id and reads the fragments back out of
`client.database()` — the SDK persists a relay's event before notifying, so the
read is always current. A `concord/list` notification re-runs `load`, which
materializes a community from each live List entry (`from_join_material`) and
keeps any state document the List does not mention, so a fresh install — or one
signing in as an account that joined elsewhere — finds its communities. See
`docs/concord-community-discovery-plan.md` (including its note on the retired
`13302` the current reference client still writes).
- **Account-key writers take any signer, not `&Keys`.** `genesis`,
`ControlWriter`, the guestbook and chat `seal_rumor`s, the `list` builders, and
the `cord05` invite writers (`build_direct_invite` / `unwrap_direct_invite`,
`build_invite_list` / `parse_invite_list`) and `cord06` blob writers
(`build_blob` / `open_blob`) are `async` and generic over the SDK's
`AsyncGetPublicKey` / `AsyncSignEvent` / `AsyncNip44` traits, so a `Keys` and an
app `UniversalSigner` both work. The NIP-59 paths (`build_direct_invite`,
`unwrap_direct_invite`) stay `Sized` because the SDK's gift-wrap helpers are.
Group-key and locally-held-secret writers (`cord01` wrap functions,
`cord05::build_bundle_event`, `store`) still take the raw key material they
genuinely need.
- **`crates/chat/src/lib.rs::handle_notifications` treats every kind 1059 event as - **`crates/chat/src/lib.rs::handle_notifications` treats every kind 1059 event as
a NIP-59 gift wrap for the current user.** Concord wraps are kind 1059 too, so a NIP-59 gift wrap for the current user.** Concord wraps are kind 1059 too, so
that handler must route by subscription id before any concord subscription goes that handler must route by subscription id before any concord subscription goes
live, or every stream wrap lands in the DM trash and raises a toast. live, or every stream wrap lands in the DM trash and raises a toast.
- **No plane key can be persisted yet.** `CommunityState` has nowhere to keep a - **Rotation-delivered plane keys cannot be persisted yet.** `CommunityState` has
key a rotation delivered and `ChannelKeyRef` carries no key of its own, so a nowhere to keep a key a rotation delivered, so a client can verify a rotation
client can verify a rotation and still lose it on restart — history under a and still lose it on restart — history under a prior root or a prior channel
prior root or a prior channel epoch is unreadable until that schema change epoch is unreadable until that schema change lands. (A granted private-channel
lands. key does now have a home: `ChannelKeyRef.key`, filled by
`CommunityState::from_join_material`.)
+1
View File
@@ -16,6 +16,7 @@ common = { path = "../crates/common" }
state = { path = "../crates/state" } state = { path = "../crates/state" }
device = { path = "../crates/device" } device = { path = "../crates/device" }
chat = { path = "../crates/chat" } chat = { path = "../crates/chat" }
community = { path = "../crates/community" }
settings = { path = "../crates/settings" } settings = { path = "../crates/settings" }
person = { path = "../crates/person" } person = { path = "../crates/person" }
+26 -25
View File
@@ -47,32 +47,33 @@ pub fn run() -> Result<(), JsValue> {
}; };
app.run(|cx| { app.run(|cx| {
// Open the root window // Initialize components
ui::init(cx);
// Initialize theme registry
theme::init(cx);
// Initialize settings
settings::init(cx);
// Initialize the nostr client
state::init(cx, None);
// Initialize person registry
person::init(cx);
// Initialize device signer
//
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
device::init(cx);
// Initialize app registry
chat::init(cx);
// Initialize community registry
community::init(cx);
cx.open_window(WindowOptions::default(), |window, cx| { cx.open_window(WindowOptions::default(), |window, cx| {
// Initialize components
ui::init(cx);
// Initialize theme registry
theme::init(cx);
// Initialize settings
settings::init(window, cx);
// Initialize the nostr client
state::init(window, cx, None);
// Initialize person registry
person::init(window, cx);
// Initialize device signer
//
// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md
device::init(window, cx);
// Initialize app registry
chat::init(window, cx);
// Root view
cx.new(|cx| Root::new(workspace::init(window, cx).into(), window, cx)) cx.new(|cx| Root::new(workspace::init(window, cx).into(), window, cx))
}) })
.expect("Failed to open window. Please restart the application."); .expect("Failed to open window. Please restart the application.");