Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29f3c74d06 | ||
|
|
3bbdc5ad34 | ||
|
|
10e53b9fcc | ||
|
|
5c9005d473 | ||
|
|
9c735febc1 | ||
|
|
8401628412 | ||
|
|
974f8302a1 | ||
|
|
564382756d | ||
|
|
52f92c226f | ||
|
|
323545ce16 | ||
|
|
9e47882fb1 | ||
|
|
bd8bff4693 | ||
|
|
b8f8737dc5 | ||
|
|
cd3f068c5e | ||
|
|
cbb97e471f | ||
|
|
97e7539cea | ||
|
|
9a391ca76d | ||
|
|
8aad0685ad | ||
|
|
1cf5ecba51 | ||
|
|
0328d35945 | ||
|
|
907347d002 | ||
|
|
aa3bd71351 | ||
|
|
d3b8fa08de | ||
|
|
9081675494 | ||
|
|
91c40b0799 | ||
|
|
1b6ef6f574 | ||
|
|
f9e538257a | ||
|
|
70140b2454 | ||
|
|
2def7548ef | ||
|
|
1320a2c361 | ||
|
|
88005fbc41 | ||
|
|
41bd0ce345 | ||
|
|
492e50746f | ||
|
|
e75b1b9f10 | ||
|
|
0ad491cb92 | ||
|
|
98903de1d0 | ||
|
|
2ccbfcd4a8 |
Generated
+41
@@ -1274,6 +1274,41 @@ dependencies = [
|
||||
"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]]
|
||||
name = "community_ui"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"common",
|
||||
"community",
|
||||
"gpui-pre",
|
||||
"log",
|
||||
"nostr-sdk",
|
||||
"person",
|
||||
"settings",
|
||||
"smallvec",
|
||||
"state",
|
||||
"theme",
|
||||
"ui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.43"
|
||||
@@ -1297,10 +1332,12 @@ name = "concord"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
"chacha20 0.9.1",
|
||||
"data-encoding",
|
||||
"hkdf",
|
||||
"hmac 0.12.1",
|
||||
"log",
|
||||
"nostr",
|
||||
"nostr-memory",
|
||||
"nostr-sdk",
|
||||
@@ -1412,6 +1449,7 @@ dependencies = [
|
||||
"auto_update",
|
||||
"chat",
|
||||
"common",
|
||||
"community",
|
||||
"device",
|
||||
"gpui-pre",
|
||||
"gpui-pre-linux",
|
||||
@@ -1437,6 +1475,7 @@ dependencies = [
|
||||
"assets",
|
||||
"chat",
|
||||
"common",
|
||||
"community",
|
||||
"console_error_panic_hook",
|
||||
"console_log",
|
||||
"device",
|
||||
@@ -9323,6 +9362,8 @@ dependencies = [
|
||||
"chat",
|
||||
"chat_ui",
|
||||
"common",
|
||||
"community",
|
||||
"community_ui",
|
||||
"device",
|
||||
"gpui-pre",
|
||||
"instant",
|
||||
|
||||
@@ -36,6 +36,7 @@ nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "ni
|
||||
|
||||
# Crypto (NIP-17 encrypted file messages)
|
||||
aes-gcm = "0.10"
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
data-encoding = "2"
|
||||
hkdf = "0.12"
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 7.75V12L15.5 15.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M2.75 4.75V8.75H6.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.25 15.0833C4.52169 18.676 7.95303 21.25 11.9864 21.25C17.1026 21.25 21.25 17.1086 21.25 12C21.25 6.89137 17.1026 2.75 11.9864 2.75C8.14808 2.75 4.85497 5.08106 3.44947 8.40278" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 600 B |
@@ -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 |
@@ -3,7 +3,7 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
|
||||
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task, Window};
|
||||
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task};
|
||||
use gpui_updater_core::{EngineConfig, Release, UpdateEngine, UpdateStatus, Verification, Version};
|
||||
use instant::Duration;
|
||||
|
||||
@@ -35,7 +35,7 @@ fn uses_managed_updates() -> bool {
|
||||
}
|
||||
|
||||
/// Initialize the auto-update system.
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
pub fn init(cx: &mut App) {
|
||||
if uses_managed_updates() {
|
||||
log::info!(
|
||||
"Skipping auto-update initialization: updates are managed by the installed distribution channel (Flatpak/Snap)"
|
||||
@@ -60,10 +60,7 @@ pub fn init(window: &mut Window, cx: &mut App) {
|
||||
return;
|
||||
};
|
||||
|
||||
AutoUpdater::set_global(
|
||||
cx.new(|cx| AutoUpdater::new(window, version, filter, cx)),
|
||||
cx,
|
||||
);
|
||||
AutoUpdater::set_global(cx.new(|cx| AutoUpdater::new(version, filter, cx)), cx);
|
||||
}
|
||||
|
||||
struct GlobalAutoUpdater(Entity<AutoUpdater>);
|
||||
@@ -103,21 +100,17 @@ impl AutoUpdater {
|
||||
cx.set_global(GlobalAutoUpdater(state));
|
||||
}
|
||||
|
||||
fn new(
|
||||
window: &mut Window,
|
||||
version: Version,
|
||||
filter: AssetFilter,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
fn new(version: Version, filter: AssetFilter, cx: &mut Context<Self>) -> Self {
|
||||
let entity = cx.entity().downgrade();
|
||||
let source = GiteaSource::new(GITEA_API_BASE, GITEA_REPO_OWNER, GITEA_REPO_NAME, filter);
|
||||
let config = EngineConfig::new(version.clone()).verification(Verification::Checksum);
|
||||
let engine = Arc::new(UpdateEngine::new(source, config));
|
||||
|
||||
// Schedule an auto-check after a 2-minute delay
|
||||
cx.defer_in(window, |_this, _window, cx| {
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.defer(move |cx| {
|
||||
cx.spawn(async move |cx| {
|
||||
cx.background_executor().timer(AUTO_CHECK_DELAY).await;
|
||||
this.update(cx, |this, cx| this.check(cx)).ok();
|
||||
entity.update(cx, |this, cx| this.check(cx)).ok();
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
|
||||
+25
-7
@@ -26,8 +26,8 @@ pub use state::FileAttachment;
|
||||
/// A static keypair used only for signing locally-cached rumor events.
|
||||
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
ChatRegistry::set_global(cx.new(|cx| ChatRegistry::new(window, cx)), cx);
|
||||
pub fn init(cx: &mut App) {
|
||||
ChatRegistry::set_global(cx.new(ChatRegistry::new), cx);
|
||||
}
|
||||
|
||||
struct GlobalChatRegistry(Entity<ChatRegistry>);
|
||||
@@ -150,7 +150,8 @@ impl ChatRegistry {
|
||||
}
|
||||
|
||||
/// 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 (tx, rx) = flume::unbounded::<Signal>();
|
||||
let mut subscriptions = smallvec![];
|
||||
@@ -167,9 +168,12 @@ impl ChatRegistry {
|
||||
}),
|
||||
);
|
||||
|
||||
// Run at the end of the current cycle
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.get_rooms(cx);
|
||||
cx.defer(move |cx| {
|
||||
entity
|
||||
.update(cx, |this, cx| {
|
||||
this.get_rooms(cx);
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
|
||||
Self {
|
||||
@@ -221,7 +225,21 @@ impl ChatRegistry {
|
||||
};
|
||||
|
||||
match *message {
|
||||
RelayMessage::Event { event, .. } => {
|
||||
RelayMessage::Event {
|
||||
subscription_id,
|
||||
event,
|
||||
..
|
||||
} => {
|
||||
let chat_sub = subscription_id.as_str() != sub_id1.as_str();
|
||||
let device_sub = subscription_id.as_str() != sub_id2.as_str();
|
||||
|
||||
// Concord wraps are also kind 1059.
|
||||
//
|
||||
// Only the two gift wrap subscriptions carry NIP-59 wraps for this account.
|
||||
if event.kind == Kind::GiftWrap && chat_sub && device_sub {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Prune the dedup set before it grows unbounded
|
||||
if processed_events.len() >= MAX_PROCESSED {
|
||||
processed_events.clear();
|
||||
|
||||
+14
-5
@@ -289,12 +289,21 @@ impl Room {
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the display image for the room
|
||||
pub fn display_image(&self, cx: &App) -> SharedString {
|
||||
if !self.is_group() {
|
||||
self.display_member(cx).avatar()
|
||||
/// Gets the display picture for the room, if it has one
|
||||
pub fn display_image(&self, cx: &App) -> Option<SharedString> {
|
||||
if self.is_group() {
|
||||
None
|
||||
} else {
|
||||
SharedString::from("brand/group.png")
|
||||
self.display_member(cx).avatar()
|
||||
}
|
||||
}
|
||||
|
||||
/// A stable seed for the room's generated avatar
|
||||
pub fn display_image_seed(&self, cx: &App) -> SharedString {
|
||||
if self.is_group() {
|
||||
SharedString::from(self.id.to_string())
|
||||
} else {
|
||||
self.display_member(cx).avatar_seed()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1203,6 +1203,7 @@ impl ChatPanel {
|
||||
if show_author {
|
||||
this.child(
|
||||
Avatar::new(author.avatar())
|
||||
.seed(author.avatar_seed())
|
||||
.flex_shrink_0()
|
||||
.relative()
|
||||
.dropdown_menu(move |this, _window, _cx| {
|
||||
@@ -1470,7 +1471,7 @@ impl ChatPanel {
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.font_semibold()
|
||||
.child(Avatar::new(avatar).small())
|
||||
.child(Avatar::new(avatar).seed(profile.avatar_seed()).small())
|
||||
.child(name.clone()),
|
||||
),
|
||||
)
|
||||
@@ -1978,11 +1979,12 @@ impl Panel for ChatPanel {
|
||||
self.room
|
||||
.read_with(cx, |this, cx| {
|
||||
let label = this.display_name(cx);
|
||||
let url = this.display_image(cx);
|
||||
let picture = this.display_image(cx);
|
||||
let seed = this.display_image_seed(cx);
|
||||
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.child(Avatar::new(url).xsmall())
|
||||
.child(Avatar::new(picture).seed(seed).xsmall())
|
||||
.child(label)
|
||||
.into_any_element()
|
||||
})
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,318 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use concord::cord02::{ControlFold, ImageRef};
|
||||
use concord::cord03::{self, ChatMessage, ReplyRef};
|
||||
use concord::cord04::roles::{Permissions, citation_ok};
|
||||
use concord::derive::channel_group_key;
|
||||
use concord::store::{self, ChannelKeyRef, CommunityState};
|
||||
use concord::{ChannelId, CommunityId, Epoch};
|
||||
use gpui::{App, AppContext, Context, EventEmitter, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::NostrRegistry;
|
||||
|
||||
use crate::sync::{self, Snapshot};
|
||||
|
||||
const MESSAGE_LIMIT: usize = 200;
|
||||
|
||||
#[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),
|
||||
Open(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)
|
||||
}
|
||||
|
||||
/// A public channel derives its plane from the community root; a private one uses its granted key.
|
||||
fn channel_secret(&self, channel: &ChannelId) -> Option<(Epoch, [u8; 32])> {
|
||||
let held = self
|
||||
.state
|
||||
.channels
|
||||
.iter()
|
||||
.find(|held| held.id == *channel)?;
|
||||
|
||||
if held.private {
|
||||
return held.key.map(|key| (held.epoch, key));
|
||||
}
|
||||
|
||||
Some((held.epoch, self.state.community_root))
|
||||
}
|
||||
|
||||
/// Page a channel's history into the local cache, once per channel.
|
||||
pub fn backfill(&self, channel: &ChannelId, cx: &App) -> Task<Result<()>> {
|
||||
let Some((epoch, secret)) = self.channel_secret(channel) else {
|
||||
return Task::ready(Ok(()));
|
||||
};
|
||||
|
||||
let client = NostrRegistry::global(cx).read(cx).client();
|
||||
let channel = *channel;
|
||||
|
||||
cx.background_spawn(async move {
|
||||
if !store::query_rumors(&client, &channel, None, 1)
|
||||
.await?
|
||||
.is_empty()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
store::backfill(&client, &channel, &[(epoch, secret)], None, MESSAGE_LIMIT).await?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// The channel's timeline, folded from the local cache, oldest first.
|
||||
pub fn messages(&self, channel: &ChannelId, cx: &App) -> Task<Result<Vec<ChatMessage>>> {
|
||||
let client = NostrRegistry::global(cx).read(cx).client();
|
||||
let channel = *channel;
|
||||
let owner = self.state.owner;
|
||||
let community_id = self.state.id;
|
||||
let floors = self.state.floors();
|
||||
let roles = self.control.roles.clone();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let cached = store::query_rumors(&client, &channel, None, MESSAGE_LIMIT).await?;
|
||||
let mut rumors = Vec::with_capacity(cached.len());
|
||||
|
||||
for rumor in &cached {
|
||||
match cord03::parse_rumor(rumor) {
|
||||
Ok(chat) => rumors.push(chat),
|
||||
Err(error) => {
|
||||
log::warn!("community: skipping an unreadable cached rumor: {error}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut messages =
|
||||
cord03::fold(&rumors, Timestamp::now(), |actor, citation, author| {
|
||||
citation_ok(&owner, &community_id, actor, citation, &floors)
|
||||
&& roles.can_act_on_member(
|
||||
actor,
|
||||
&owner,
|
||||
author,
|
||||
Permissions::MANAGE_MESSAGES,
|
||||
)
|
||||
});
|
||||
|
||||
messages.reverse();
|
||||
|
||||
Ok(messages)
|
||||
})
|
||||
}
|
||||
|
||||
/// Seal a message to the channel plane, cache it, then publish it to the relays.
|
||||
pub fn send(
|
||||
&self,
|
||||
channel: &ChannelId,
|
||||
content: &str,
|
||||
reply_to: Option<ReplyRef>,
|
||||
cx: &App,
|
||||
) -> Option<Task<Result<EventId>>> {
|
||||
let (epoch, secret) = self.channel_secret(channel)?;
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
let author = nostr.read(cx).current_user()?;
|
||||
|
||||
let channel = *channel;
|
||||
let relays = self.state.relays.clone();
|
||||
let timer = self
|
||||
.control
|
||||
.community
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.message_expiration);
|
||||
let content = content.to_owned();
|
||||
|
||||
Some(cx.background_spawn(async move {
|
||||
let group = channel_group_key(&secret, &channel, epoch)?;
|
||||
let at_ms = Timestamp::now().as_secs().saturating_mul(1000);
|
||||
|
||||
let rumor = cord03::build_message(
|
||||
author,
|
||||
&channel,
|
||||
epoch,
|
||||
&content,
|
||||
reply_to.as_ref(),
|
||||
at_ms,
|
||||
timer,
|
||||
);
|
||||
let (wrap, _) = cord03::seal_rumor(&rumor, &group, &signer, false).await?;
|
||||
|
||||
let (opened, _) = cord03::open(&wrap, &group, &channel, epoch)?;
|
||||
store::cache_rumor(&client, &channel, &opened).await?;
|
||||
|
||||
sync::connect_relays(&client, &relays).await;
|
||||
sync::publish_wrap(&client, &wrap, &relays).await;
|
||||
|
||||
Ok(opened.rumor_id)
|
||||
}))
|
||||
}
|
||||
|
||||
/// 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(())
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::Result;
|
||||
use concord::cord01::KIND_WRAP;
|
||||
pub use concord::cord02::CommunityMetadata;
|
||||
pub use concord::cord03::{ChatMessage, ReplyRef};
|
||||
use concord::store::CommunityState;
|
||||
pub use concord::{ChannelId, CommunityId};
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, Window};
|
||||
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()
|
||||
}
|
||||
|
||||
/// Ask the workspace to open a community's panel.
|
||||
pub fn emit_community(
|
||||
&mut self,
|
||||
community: &Entity<Community>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let id = community.read(cx).id();
|
||||
|
||||
cx.defer_in(window, move |_this, _window, cx| {
|
||||
cx.emit(CommunityEvent::Open(id));
|
||||
});
|
||||
}
|
||||
|
||||
/// 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(())
|
||||
}
|
||||
@@ -0,0 +1,921 @@
|
||||
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]) {
|
||||
connect_relays(client, relays).await;
|
||||
|
||||
for wrap in wraps {
|
||||
publish_wrap(client, wrap, relays).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring the community's relays into the pool before anything is sent through them.
|
||||
pub(crate) async fn connect_relays(client: &Client, relays: &[RelayUrl]) {
|
||||
for url in relays {
|
||||
if let Err(error) = client.add_relay(url).and_connect().await {
|
||||
log::warn!("community: failed to add relay {url}: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort publication of a single wrap to the community's relays.
|
||||
pub(crate) async fn publish_wrap(client: &Client, wrap: &Event, relays: &[RelayUrl]) {
|
||||
let sent = if relays.is_empty() {
|
||||
client.send_event(wrap).broadcast().await
|
||||
} else {
|
||||
client.send_event(wrap).to(relays.iter().cloned()).await
|
||||
};
|
||||
|
||||
match sent {
|
||||
Ok(output) if output.failed.is_empty() => {}
|
||||
Ok(output) => log::warn!(
|
||||
"community: {} relay(s) rejected {}",
|
||||
output.failed.len(),
|
||||
wrap.id
|
||||
),
|
||||
Err(error) => log::warn!("community: publishing {} failed: {error}", wrap.id),
|
||||
}
|
||||
}
|
||||
|
||||
async fn record_membership<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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "community_ui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
community = { path = "../community" }
|
||||
state = { path = "../state" }
|
||||
ui = { path = "../ui" }
|
||||
theme = { path = "../theme" }
|
||||
common = { path = "../common" }
|
||||
person = { path = "../person" }
|
||||
settings = { path = "../settings" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
smallvec.workspace = true
|
||||
log.workspace = true
|
||||
@@ -0,0 +1,482 @@
|
||||
use anyhow::Result;
|
||||
use community::{ChannelId, ChatMessage, Community, CommunityEvent};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
InteractiveElement, IntoElement, ListAlignment, ListState, ParentElement, Render, SharedString,
|
||||
StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, Window, div, list, px,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use settings::AppSettings;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock::{Panel, PanelEvent};
|
||||
use ui::input::{InputEvent, Textarea, TextareaState};
|
||||
use ui::notification::Notification;
|
||||
use ui::scroll::{ScrollableElement, Scrollbar};
|
||||
use ui::{Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||
|
||||
mod message;
|
||||
|
||||
pub fn init(
|
||||
community: Entity<Community>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<CommunityPanel> {
|
||||
cx.new(|cx| CommunityPanel::new(community, window, cx))
|
||||
}
|
||||
|
||||
/// Community Panel
|
||||
pub struct CommunityPanel {
|
||||
id: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
|
||||
/// Community
|
||||
community: WeakEntity<Community>,
|
||||
|
||||
/// The selected channel
|
||||
channel: Option<ChannelId>,
|
||||
|
||||
/// The selected channel's timeline (oldest first)
|
||||
messages: Vec<ChatMessage>,
|
||||
|
||||
/// Message list state
|
||||
list_state: ListState,
|
||||
|
||||
/// Message input state
|
||||
input: Entity<TextareaState>,
|
||||
|
||||
/// Async operations
|
||||
tasks: Vec<Task<Result<()>>>,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
|
||||
impl CommunityPanel {
|
||||
pub fn new(community: Entity<Community>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let (id, name, channel) = {
|
||||
let community = community.read(cx);
|
||||
|
||||
(
|
||||
SharedString::from(format!("community-{}", community.id().to_hex())),
|
||||
community.name(),
|
||||
community.channels().first().map(|channel| channel.id),
|
||||
)
|
||||
};
|
||||
|
||||
let input = cx.new(|cx| {
|
||||
TextareaState::new(window, cx)
|
||||
.placeholder(format!("Message {name}"))
|
||||
.auto_grow(1, 20)
|
||||
.clean_on_escape()
|
||||
});
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.send(window, cx);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&community,
|
||||
window,
|
||||
|_this, _community, event, window, cx| match event {
|
||||
// The fold holds the community, so reload once it is released.
|
||||
CommunityEvent::Updated(_) => {
|
||||
cx.defer_in(window, |this, window, cx| this.reload(window, cx));
|
||||
}
|
||||
CommunityEvent::Error(error) => {
|
||||
window
|
||||
.push_notification(Notification::error(error.clone()).autohide(false), cx);
|
||||
}
|
||||
CommunityEvent::Open(_) => {}
|
||||
},
|
||||
));
|
||||
|
||||
let panel = Self {
|
||||
id,
|
||||
focus_handle: cx.focus_handle(),
|
||||
community: community.downgrade(),
|
||||
channel,
|
||||
messages: Vec::new(),
|
||||
list_state: ListState::new(0, ListAlignment::Bottom, px(1024.)),
|
||||
input,
|
||||
tasks: Vec::new(),
|
||||
_subscriptions: subscriptions,
|
||||
};
|
||||
|
||||
cx.defer_in(window, |this, window, cx| this.load(window, cx));
|
||||
|
||||
panel
|
||||
}
|
||||
|
||||
/// Page the selected channel's history into the cache, then read it back.
|
||||
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(channel) = self.channel else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(backfill) = self
|
||||
.community
|
||||
.read_with(cx, |community, cx| community.backfill(&channel, cx))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
if let Err(error) = backfill.await {
|
||||
log::warn!("community panel: backfill failed: {error}");
|
||||
}
|
||||
|
||||
this.update_in(cx, |this, window, cx| this.reload(window, cx))?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Replace the timeline with the selected channel's folded messages.
|
||||
fn reload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(channel) = self.channel else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(messages) = self
|
||||
.community
|
||||
.read_with(cx, |community, cx| community.messages(&channel, cx))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
match messages.await {
|
||||
Ok(messages) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.messages = messages;
|
||||
this.list_state.reset(this.messages.len());
|
||||
this.list_state.scroll_to_end();
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
Err(error) => {
|
||||
this.update_in(cx, |_this, window, cx| {
|
||||
window.push_notification(
|
||||
Notification::error(error.to_string()).autohide(false),
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
fn select_channel(&mut self, channel: ChannelId, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.channel == Some(channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.channel = Some(channel);
|
||||
self.messages.clear();
|
||||
self.list_state.reset(0);
|
||||
cx.notify();
|
||||
|
||||
self.load(window, cx);
|
||||
}
|
||||
|
||||
fn send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let content = self.input.read(cx).value().trim().to_owned();
|
||||
|
||||
if content.is_empty() {
|
||||
window.push_notification("Cannot send an empty message", cx);
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(channel) = self.channel else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(send) = self.community.read_with(cx, |community, cx| {
|
||||
community.send(&channel, &content, None, cx)
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(send) = send else {
|
||||
window.push_notification(Notification::error("Failed to send the message"), cx);
|
||||
return;
|
||||
};
|
||||
|
||||
self.input.update(cx, |input, cx| {
|
||||
input.set_value("", window, cx);
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
match send.await {
|
||||
Ok(_) => {
|
||||
this.update_in(cx, |this, window, cx| this.reload(window, cx))?;
|
||||
}
|
||||
Err(error) => {
|
||||
this.update_in(cx, |_this, window, cx| {
|
||||
window.push_notification(
|
||||
Notification::error(error.to_string()).autohide(false),
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
fn render_channel(
|
||||
&self,
|
||||
id: ChannelId,
|
||||
name: &str,
|
||||
private: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let selected = self.channel == Some(id);
|
||||
|
||||
h_flex()
|
||||
.id(SharedString::from(format!(
|
||||
"community-channel-{}",
|
||||
id.to_hex()
|
||||
)))
|
||||
.w_full()
|
||||
.h_8()
|
||||
.flex_shrink_0()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.rounded(cx.theme().radius)
|
||||
.cursor_pointer()
|
||||
.when(selected, |this| this.bg(cx.theme().ghost_element_selected))
|
||||
.hover(|this| this.bg(cx.theme().ghost_element_hover))
|
||||
.child(
|
||||
Icon::new(if private {
|
||||
IconName::Lock
|
||||
} else {
|
||||
IconName::Message
|
||||
})
|
||||
.small()
|
||||
.text_color(cx.theme().icon_muted),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_ellipsis()
|
||||
.child(SharedString::from(name.to_owned())),
|
||||
)
|
||||
.on_click(cx.listener(move |this, _event, window, cx| {
|
||||
this.select_channel(id, window, cx);
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_timeline(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.h_full()
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.relative()
|
||||
.map(|this| {
|
||||
if self.messages.is_empty() {
|
||||
this.child(
|
||||
h_flex()
|
||||
.size_full()
|
||||
.justify_center()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child("No messages yet"),
|
||||
)
|
||||
} else {
|
||||
this.child(
|
||||
list(
|
||||
self.list_state.clone(),
|
||||
cx.processor(move |this, ix, window, cx| {
|
||||
this.render_message(ix, window, cx)
|
||||
}),
|
||||
)
|
||||
.size_full(),
|
||||
)
|
||||
}
|
||||
})
|
||||
.child(Scrollbar::vertical(&self.list_state)),
|
||||
)
|
||||
.child(self.render_composer(cx))
|
||||
}
|
||||
|
||||
fn render_message(
|
||||
&mut self,
|
||||
ix: usize,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let Some(message) = self.messages.get(ix) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
message::render(ix, message, cx)
|
||||
}
|
||||
|
||||
fn render_composer(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.w_full()
|
||||
.p_2()
|
||||
.gap_1()
|
||||
.items_end()
|
||||
.border_t_1()
|
||||
.border_color(cx.theme().border)
|
||||
.child(Textarea::new(&self.input).appearance(false).flex_1())
|
||||
.child(
|
||||
Button::new("send")
|
||||
.icon(IconName::PaperPlaneFill)
|
||||
.tooltip("Send")
|
||||
.ghost()
|
||||
.large()
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
this.send(window, cx);
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for CommunityPanel {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
fn title(&self, cx: &App) -> AnyElement {
|
||||
self.community
|
||||
.read_with(cx, |community, _cx| {
|
||||
let seed = community.id().to_hex();
|
||||
let avatar = match community.icon() {
|
||||
Some(path) => Avatar::from_source(path).seed(seed).xsmall(),
|
||||
None => Avatar::new(None).seed(seed).xsmall(),
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.child(avatar)
|
||||
.child(SharedString::from(community.name()))
|
||||
.into_any_element()
|
||||
})
|
||||
.unwrap_or_else(|_| div().child("Unknown").into_any_element())
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for CommunityPanel {}
|
||||
|
||||
impl Focusable for CommunityPanel {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for CommunityPanel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let Some(community) = self.community.upgrade() else {
|
||||
return div().size_full();
|
||||
};
|
||||
|
||||
let (channels, members) = {
|
||||
let community = community.read(cx);
|
||||
|
||||
(
|
||||
community
|
||||
.channels()
|
||||
.iter()
|
||||
.map(|channel| (channel.id, channel.name.clone(), channel.private))
|
||||
.collect::<Vec<_>>(),
|
||||
community.members().iter().copied().collect::<Vec<_>>(),
|
||||
)
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.flex_row()
|
||||
.child(
|
||||
v_flex()
|
||||
.id("community-sidebar")
|
||||
.w(px(220.))
|
||||
.h_full()
|
||||
.flex_shrink_0()
|
||||
.gap_1()
|
||||
.p_2()
|
||||
.border_r_1()
|
||||
.border_color(cx.theme().border)
|
||||
.overflow_y_scrollbar()
|
||||
.child(section_label("Channels", cx))
|
||||
.children(
|
||||
channels.iter().map(|(id, name, private)| {
|
||||
self.render_channel(*id, name, *private, cx)
|
||||
}),
|
||||
)
|
||||
.child(section_label("Members", cx))
|
||||
.children(
|
||||
members
|
||||
.iter()
|
||||
.map(|public_key| render_member(*public_key, cx)),
|
||||
),
|
||||
)
|
||||
.child(self.render_timeline(cx))
|
||||
}
|
||||
}
|
||||
|
||||
fn section_label(label: &str, cx: &App) -> impl IntoElement {
|
||||
div()
|
||||
.px_2()
|
||||
.pt_2()
|
||||
.pb_1()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(label.to_owned()))
|
||||
}
|
||||
|
||||
fn render_member(public_key: PublicKey, cx: &App) -> AnyElement {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let person = persons.read(cx).get(&public_key, cx);
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
.h_8()
|
||||
.flex_shrink_0()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.when(!hide_avatar, |this| {
|
||||
this.child(
|
||||
Avatar::new(person.avatar())
|
||||
.seed(person.avatar_seed())
|
||||
.xsmall(),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_ellipsis()
|
||||
.child(person.name()),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use common::TimestampExt;
|
||||
use community::ChatMessage;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, InteractiveElement, IntoElement, ParentElement, SharedString, Styled, div,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use settings::AppSettings;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::{StyledExt, h_flex, v_flex};
|
||||
|
||||
pub(crate) fn render(ix: usize, message: &ChatMessage, cx: &App) -> AnyElement {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let author = persons.read(cx).get(&message.author, cx);
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
|
||||
div()
|
||||
.id(ix)
|
||||
.w_full()
|
||||
.py_1()
|
||||
.px_3()
|
||||
.hover(|this| this.bg(cx.theme().surface_background))
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_3()
|
||||
.when(!hide_avatar, |this| {
|
||||
this.child(
|
||||
Avatar::new(author.avatar())
|
||||
.seed(author.avatar_seed())
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.gap_0p5()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(div().font_semibold().child(author.name()))
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(Timestamp::from_secs(message.at_ms / 1000).to_ago()),
|
||||
)
|
||||
.when(message.edited_at.is_some(), |this| {
|
||||
this.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child("(edited)"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(content(message, cx))
|
||||
.when(!message.reactions.is_empty(), |this| {
|
||||
this.child(reactions(message, cx))
|
||||
}),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn content(message: &ChatMessage, cx: &App) -> AnyElement {
|
||||
if message.deleted {
|
||||
return div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child("Message deleted")
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
div()
|
||||
.text_sm()
|
||||
.child(SharedString::from(message.content.clone()))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn reactions(message: &ChatMessage, cx: &App) -> AnyElement {
|
||||
let mut grouped: BTreeMap<&str, usize> = BTreeMap::new();
|
||||
|
||||
for emoji in message.reactions.values() {
|
||||
*grouped.entry(emoji.as_str()).or_default() += 1;
|
||||
}
|
||||
|
||||
h_flex()
|
||||
.mt_1()
|
||||
.gap_1()
|
||||
.children(grouped.into_iter().map(|(emoji, count)| {
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.py_0p5()
|
||||
.px_1()
|
||||
.rounded(cx.theme().radius)
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.text_xs()
|
||||
.child(SharedString::from(emoji))
|
||||
.child(SharedString::from(count.to_string()))
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -13,10 +13,12 @@ sha2.workspace = true
|
||||
chacha20.workspace = true
|
||||
hmac.workspace = true
|
||||
data-encoding.workspace = true
|
||||
base64.workspace = true
|
||||
rand.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
nostr-memory.workspace = true
|
||||
|
||||
@@ -3,8 +3,8 @@ use std::fmt;
|
||||
use data_encoding::BASE64;
|
||||
use nostr::nips::nip44::v2::{ConversationKey, decrypt_to_bytes, encrypt_to_bytes_with_nonce};
|
||||
use nostr_sdk::prelude::{
|
||||
Event, EventBuilder, EventId, FinalizeEvent, Keys, Kind, PublicKey, Tag, Timestamp,
|
||||
UnsignedEvent,
|
||||
AsyncGetPublicKey, AsyncNip44, AsyncSignEvent, Event, EventBuilder, EventId, FinalizeEvent,
|
||||
FinalizeEventAsync, Keys, Kind, PublicKey, Tag, Timestamp, UnsignedEvent,
|
||||
};
|
||||
|
||||
use crate::derive::GroupKey;
|
||||
@@ -17,8 +17,8 @@ pub const KIND_SEAL_PLAINTEXT: u16 = 20014;
|
||||
pub const NIP44_MAX_PLAINTEXT: usize = 65_535;
|
||||
|
||||
const TAG_MS: &str = "ms";
|
||||
const TAG_CHANNEL: &str = "channel";
|
||||
const TAG_EPOCH: &str = "epoch";
|
||||
pub(crate) const TAG_CHANNEL: &str = "channel";
|
||||
pub(crate) const TAG_EPOCH: &str = "epoch";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SealForm {
|
||||
@@ -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.
|
||||
pub fn seal_to_self(keys: &Keys, plaintext: &[u8]) -> Result<String, StreamError> {
|
||||
seal_bytes(
|
||||
&ConversationKey::derive(keys.secret_key(), &keys.public_key())
|
||||
.map_err(|error| StreamError::Encrypt(error.to_string()))?,
|
||||
plaintext,
|
||||
)
|
||||
pub async fn seal_to_self<S>(signer: &S, plaintext: &str) -> Result<String, StreamError>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||
{
|
||||
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> {
|
||||
open_bytes(
|
||||
&ConversationKey::derive(keys.secret_key(), &keys.public_key())
|
||||
.map_err(|error| StreamError::Decrypt(error.to_string()))?,
|
||||
content,
|
||||
)
|
||||
pub async fn open_to_self<S>(signer: &S, content: &str) -> Result<String, StreamError>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||
{
|
||||
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,
|
||||
form: SealForm,
|
||||
group: &GroupKey,
|
||||
author: &Keys,
|
||||
) -> Result<Event, StreamError> {
|
||||
author: &S,
|
||||
) -> Result<Event, StreamError>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
let content = seal_content(rumor, form, group)?;
|
||||
EventBuilder::new(Kind::Custom(form.kind()), content)
|
||||
.custom_created_at(rumor.created_at)
|
||||
.finalize(author)
|
||||
.finalize_async(author)
|
||||
.await
|
||||
.map_err(|error| StreamError::Sign(error.to_string()))
|
||||
}
|
||||
|
||||
@@ -400,7 +420,10 @@ fn check_plaintext_cap(len: usize) -> Result<(), StreamError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unique_tag(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<String>, StreamError> {
|
||||
pub(crate) fn unique_tag(
|
||||
rumor: &UnsignedEvent,
|
||||
name: &'static str,
|
||||
) -> Result<Option<String>, StreamError> {
|
||||
let mut found: Option<String> = None;
|
||||
|
||||
for tag in rumor.tags.iter() {
|
||||
@@ -450,7 +473,7 @@ mod tests {
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
|
||||
use anyhow::Result;
|
||||
use data_encoding::HEXLOWER;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::cord01::{
|
||||
KIND_WRAP, OpenedStream, SealForm, StreamError, build_rumor_ms, build_seal, open_wrap,
|
||||
wrap_seal,
|
||||
};
|
||||
use crate::cord04::{
|
||||
AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag,
|
||||
KIND_WRAP, OpenedStream, SealForm, build_rumor_ms, build_seal, open_wrap, wrap_seal,
|
||||
};
|
||||
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};
|
||||
|
||||
pub const KIND_JOIN_LEAVE: u16 = 3306;
|
||||
@@ -29,41 +27,6 @@ const TAG_CONTENT: &str = "content";
|
||||
const CONTENT_JOIN: &str = "join";
|
||||
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)]
|
||||
pub enum GuestbookEntry {
|
||||
Join {
|
||||
@@ -183,18 +146,21 @@ pub fn build_snapshot_chunks(
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn seal_rumor(
|
||||
pub async fn seal_rumor<S>(
|
||||
rumor: &UnsignedEvent,
|
||||
group: &GroupKey,
|
||||
author: &Keys,
|
||||
) -> Result<(Event, Keys), GuestbookError> {
|
||||
author: &S,
|
||||
) -> Result<(Event, Keys), GuestbookError>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
let kind = rumor.kind.as_u16();
|
||||
|
||||
if !is_guestbook_kind(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, &[])?)
|
||||
}
|
||||
@@ -466,73 +432,21 @@ fn snapshot_of(rumor: &UnsignedEvent) -> Result<([u8; 32], (u32, u32)), Guestboo
|
||||
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> {
|
||||
canonical_decimal(raw)
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
.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> {
|
||||
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)]
|
||||
mod tests {
|
||||
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::{CommunityId, Epoch};
|
||||
|
||||
@@ -556,7 +470,9 @@ mod tests {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -826,7 +742,9 @@ mod tests {
|
||||
);
|
||||
assert!(matches!(
|
||||
open(
|
||||
&seal_rumor(&bad_ms, &group(), &member).expect("seals").0,
|
||||
&smol::block_on(seal_rumor(&bad_ms, &group(), &member))
|
||||
.expect("seals")
|
||||
.0,
|
||||
&group()
|
||||
),
|
||||
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);
|
||||
assert!(matches!(
|
||||
open(
|
||||
&seal_rumor(&bad_verb, &group(), &member).expect("seals").0,
|
||||
&smol::block_on(seal_rumor(&bad_verb, &group(), &member))
|
||||
.expect("seals")
|
||||
.0,
|
||||
&group()
|
||||
),
|
||||
Err(GuestbookError::BadTag(TAG_CONTENT))
|
||||
@@ -854,7 +774,7 @@ mod tests {
|
||||
);
|
||||
assert!(matches!(
|
||||
open(
|
||||
&seal_rumor(&ambiguous, &group(), &moderator)
|
||||
&smol::block_on(seal_rumor(&ambiguous, &group(), &moderator))
|
||||
.expect("seals")
|
||||
.0,
|
||||
&group()
|
||||
@@ -876,7 +796,9 @@ mod tests {
|
||||
);
|
||||
assert!(matches!(
|
||||
open(
|
||||
&seal_rumor(&rumor, &group(), &moderator).expect("seals").0,
|
||||
&smol::block_on(seal_rumor(&rumor, &group(), &moderator))
|
||||
.expect("seals")
|
||||
.0,
|
||||
&group()
|
||||
),
|
||||
Err(GuestbookError::BadTag(TAG_SNAP))
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::btree_map::Entry;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
|
||||
use data_encoding::HEXLOWER;
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
use crate::cord01::{self, NIP44_MAX_PLAINTEXT};
|
||||
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;
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -17,6 +19,8 @@ pub enum ListError {
|
||||
Kind(u16),
|
||||
Crypto(String),
|
||||
Json(String),
|
||||
Encoding(String),
|
||||
Fragment(String),
|
||||
TooManyMemberships(usize),
|
||||
Oversize(usize),
|
||||
}
|
||||
@@ -27,6 +31,8 @@ impl fmt::Display for ListError {
|
||||
ListError::Kind(kind) => write!(f, "not a community list kind: {kind}"),
|
||||
ListError::Crypto(error) => write!(f, "crypto: {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) => {
|
||||
write!(
|
||||
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 community_id: CommunityId,
|
||||
pub owner: PublicKey,
|
||||
pub owner_salt: String,
|
||||
pub community_root: String,
|
||||
pub root_epoch: Epoch,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub control_pk: Option<PublicKey>,
|
||||
/// Present only when the holder is staff.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub control_root: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub channels: Vec<ChannelGrant>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub relays: Vec<String>,
|
||||
pub name: String,
|
||||
#[serde(flatten)]
|
||||
pub extra: Extra,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CommunityListEntry {
|
||||
pub community_id: CommunityId,
|
||||
pub seed: JoinMaterial,
|
||||
pub current: JoinMaterial,
|
||||
pub added_at: u64,
|
||||
#[serde(flatten)]
|
||||
pub extra: Extra,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Tombstone {
|
||||
pub community_id: CommunityId,
|
||||
pub removed_at: u64,
|
||||
#[serde(flatten)]
|
||||
pub extra: Extra,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
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>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tombstones: Vec<Tombstone>,
|
||||
#[serde(flatten)]
|
||||
pub extra: Extra,
|
||||
}
|
||||
|
||||
impl Default for CommunityList {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
frags: 1,
|
||||
entries: Vec::new(),
|
||||
tombstones: Vec::new(),
|
||||
extra: Extra::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CommunityList {
|
||||
pub fn is_live(&self, community_id: &CommunityId) -> bool {
|
||||
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> {
|
||||
if self.entries.len() > MAX_MEMBERSHIPS {
|
||||
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(),
|
||||
root_epoch: invite.root_epoch,
|
||||
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(),
|
||||
relays: invite.relays.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 {
|
||||
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) {
|
||||
Entry::Vacant(slot) => {
|
||||
slot.insert(entry);
|
||||
@@ -177,31 +220,361 @@ pub fn merge(held: CommunityList, incoming: CommunityList) -> CommunityList {
|
||||
union(&mut extra, incoming.extra);
|
||||
|
||||
CommunityList {
|
||||
frags: held.frags.max(incoming.frags),
|
||||
entries: entries.into_values().collect(),
|
||||
tombstones: tombstones.into_values().collect(),
|
||||
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()?;
|
||||
|
||||
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)
|
||||
.finalize(keys)
|
||||
.tag(Tag::identifier(fragment.to_string()))
|
||||
.custom_created_at(Timestamp::from_secs(at_secs))
|
||||
.finalize_async(signer)
|
||||
.await
|
||||
.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 {
|
||||
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)]
|
||||
@@ -215,6 +588,7 @@ fn merge_entry(held: &mut CommunityListEntry, incoming: CommunityListEntry) {
|
||||
held.seed = pick(&held.seed, &incoming.seed, Snapshot::Seed).clone();
|
||||
held.current = pick(&held.current, &incoming.current, Snapshot::Current).clone();
|
||||
union(&mut held.extra, incoming.extra);
|
||||
normalize(held);
|
||||
}
|
||||
|
||||
fn pick<'a>(
|
||||
@@ -238,21 +612,26 @@ fn pick<'a>(
|
||||
held
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
fn normalize(entry: &mut CommunityListEntry) {
|
||||
entry.seed.community_id = entry.community_id;
|
||||
entry.current.community_id = entry.community_id;
|
||||
normalize_snapshot(&mut entry.seed, &entry.current);
|
||||
}
|
||||
|
||||
pub(crate) fn canonical<T: Serialize>(value: &T) -> String {
|
||||
serde_json::to_string(value).unwrap_or_default()
|
||||
fn normalize_snapshot(seed: &mut JoinMaterial, current: &JoinMaterial) {
|
||||
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 {
|
||||
@@ -358,7 +737,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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 joined = entry(
|
||||
id(0x11),
|
||||
@@ -378,22 +757,66 @@ mod tests {
|
||||
// A stale device re-merging the entry cannot resurrect it.
|
||||
assert!(!merge(left.clone(), list(vec![joined.clone()])).is_live(&id(0x11)));
|
||||
|
||||
// A re-join genuinely newer than the removal does.
|
||||
let rejoined = list(vec![entry(
|
||||
id(0x11),
|
||||
material(id(0x11), owner, "Room", 0),
|
||||
material(id(0x11), owner, "Room", 0),
|
||||
7_000,
|
||||
)]);
|
||||
let live = merge(left, rejoined);
|
||||
assert!(live.is_live(&id(0x11)));
|
||||
// The tombstone alone is written, so the entry's key material leaves the wire.
|
||||
let written = serde_json::to_string(&left).expect("writes");
|
||||
assert!(
|
||||
!written.contains("\"entries\""),
|
||||
"a retired entry is not written"
|
||||
);
|
||||
let reparsed: CommunityList = serde_json::from_str(&written).expect("parses");
|
||||
assert_eq!(reparsed.tombstones.len(), 1);
|
||||
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.
|
||||
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]
|
||||
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 owner = Keys::generate().public_key();
|
||||
let mine = CommunityList {
|
||||
@@ -416,23 +839,32 @@ mod tests {
|
||||
removed_at: AT,
|
||||
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!(parse_list_event(&me, &event).expect("parses"), mine);
|
||||
assert!(
|
||||
!parse_list_event(&me, &event)
|
||||
.expect("parses")
|
||||
.is_live(&id(0x33))
|
||||
assert_eq!(fragment_index(&event).expect("a fragment index"), 1);
|
||||
assert_eq!(
|
||||
smol::block_on(parse_list_event(&me, &event)).expect("parses"),
|
||||
mine
|
||||
);
|
||||
|
||||
// Only the member's own keys open it, and an unreadable list is "no news".
|
||||
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();
|
||||
held.extra
|
||||
.insert("future".to_owned(), serde_json::json!({"deep": [1, 2]}));
|
||||
@@ -440,8 +872,21 @@ mod tests {
|
||||
.current
|
||||
.extra
|
||||
.insert("held_roots".to_owned(), serde_json::json!([{"epoch": 1}]));
|
||||
let rebuilt =
|
||||
parse_list_event(&me, &build_list_event(&me, &held).expect("builds")).expect("parses");
|
||||
held.entries[0].current.channels = vec![ChannelGrant {
|
||||
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);
|
||||
|
||||
// The write gate refuses an over-cap or oversized List before publishing.
|
||||
@@ -459,18 +904,146 @@ mod tests {
|
||||
.collect(),
|
||||
);
|
||||
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
|
||||
));
|
||||
|
||||
let oversized = list(vec![entry(
|
||||
id(0x11),
|
||||
material(id(0x11), owner, &"x".repeat(NIP44_MAX_PLAINTEXT), 0),
|
||||
material(id(0x11), owner, "Room", 0),
|
||||
material(id(0x11), owner, &"x".repeat(NIP44_MAX_PLAINTEXT), 0),
|
||||
AT,
|
||||
)]);
|
||||
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_SECS: u64 = AT / 1000;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ pub mod list;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
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 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 fn genesis(
|
||||
owner: &Keys,
|
||||
pub async fn genesis<S>(
|
||||
owner: &S,
|
||||
metadata: &CommunityMetadata,
|
||||
at_secs: u64,
|
||||
) -> Result<CommunityGenesis> {
|
||||
) -> Result<CommunityGenesis>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
let metadata_content = encode_metadata(metadata)?;
|
||||
let owner_salt = random_32()?;
|
||||
let owner_key = owner
|
||||
.get_public_key_async()
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("signer: {error}"))?;
|
||||
|
||||
let identity = CommunityIdentity {
|
||||
community_id: community_id_of(&owner.public_key().to_bytes(), &owner_salt),
|
||||
owner: owner.public_key(),
|
||||
community_id: community_id_of(&owner_key.to_bytes(), &owner_salt),
|
||||
owner: owner_key,
|
||||
owner_salt,
|
||||
};
|
||||
|
||||
@@ -167,7 +176,7 @@ pub fn genesis(
|
||||
let mut wraps = Vec::with_capacity(editions.len());
|
||||
|
||||
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 {
|
||||
@@ -211,12 +220,15 @@ pub struct Edition<'a> {
|
||||
}
|
||||
|
||||
impl ControlWriter {
|
||||
pub fn publish(
|
||||
pub async fn publish<S>(
|
||||
&self,
|
||||
keys: &Keys,
|
||||
keys: &S,
|
||||
edition: Edition<'_>,
|
||||
at_secs: u64,
|
||||
) -> Result<(Event, EntityHead)> {
|
||||
) -> Result<(Event, EntityHead)>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
let rumor = build_edition(EditionFields {
|
||||
author: self.author,
|
||||
subkind: edition.subkind,
|
||||
@@ -231,20 +243,23 @@ impl ControlWriter {
|
||||
});
|
||||
|
||||
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)))
|
||||
}
|
||||
|
||||
pub fn set_community_metadata(
|
||||
pub async fn set_community_metadata<S>(
|
||||
&self,
|
||||
keys: &Keys,
|
||||
keys: &S,
|
||||
community_id: &CommunityId,
|
||||
metadata: &CommunityMetadata,
|
||||
head: Option<&EntityHead>,
|
||||
citation: Option<AuthorityCitation>,
|
||||
at_secs: u64,
|
||||
) -> Result<(Event, EntityHead)> {
|
||||
) -> Result<(Event, EntityHead)>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
let content = encode_metadata(metadata)?;
|
||||
|
||||
self.publish(
|
||||
@@ -258,17 +273,21 @@ impl ControlWriter {
|
||||
},
|
||||
at_secs,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn set_channel_metadata(
|
||||
pub async fn set_channel_metadata<S>(
|
||||
&self,
|
||||
keys: &Keys,
|
||||
keys: &S,
|
||||
channel: &ChannelId,
|
||||
metadata: &ChannelMetadata,
|
||||
head: Option<&EntityHead>,
|
||||
citation: Option<AuthorityCitation>,
|
||||
at_secs: u64,
|
||||
) -> Result<(Event, EntityHead)> {
|
||||
) -> Result<(Event, EntityHead)>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
if metadata.name.len() > MAX_NAME_BYTES {
|
||||
bail!("channel name exceeds {MAX_NAME_BYTES} bytes");
|
||||
}
|
||||
@@ -286,16 +305,20 @@ impl ControlWriter {
|
||||
},
|
||||
at_secs,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn set_role(
|
||||
pub async fn set_role<S>(
|
||||
&self,
|
||||
keys: &Keys,
|
||||
keys: &S,
|
||||
role: &Role,
|
||||
head: Option<&EntityHead>,
|
||||
citation: Option<AuthorityCitation>,
|
||||
at_secs: u64,
|
||||
) -> Result<(Event, EntityHead)> {
|
||||
) -> Result<(Event, EntityHead)>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
if role.name.len() > MAX_NAME_BYTES {
|
||||
bail!("role name exceeds {MAX_NAME_BYTES} bytes");
|
||||
}
|
||||
@@ -313,17 +336,21 @@ impl ControlWriter {
|
||||
},
|
||||
at_secs,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn set_grant(
|
||||
pub async fn set_grant<S>(
|
||||
&self,
|
||||
keys: &Keys,
|
||||
keys: &S,
|
||||
community_id: &CommunityId,
|
||||
grant: &Grant,
|
||||
head: Option<&EntityHead>,
|
||||
citation: Option<AuthorityCitation>,
|
||||
at_secs: u64,
|
||||
) -> Result<(Event, EntityHead)> {
|
||||
) -> Result<(Event, EntityHead)>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
let content = grant.to_content()?;
|
||||
|
||||
self.publish(
|
||||
@@ -337,17 +364,21 @@ impl ControlWriter {
|
||||
},
|
||||
at_secs,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn set_banlist(
|
||||
pub async fn set_banlist<S>(
|
||||
&self,
|
||||
keys: &Keys,
|
||||
keys: &S,
|
||||
community_id: &CommunityId,
|
||||
banned: &BTreeSet<PublicKey>,
|
||||
head: Option<&EntityHead>,
|
||||
citation: Option<AuthorityCitation>,
|
||||
at_secs: u64,
|
||||
) -> Result<(Event, EntityHead)> {
|
||||
) -> Result<(Event, EntityHead)>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
if banned.len() > MAX_BANLIST {
|
||||
bail!("banlist exceeds {MAX_BANLIST} entries");
|
||||
}
|
||||
@@ -366,19 +397,23 @@ impl ControlWriter {
|
||||
},
|
||||
at_secs,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn set_registry(
|
||||
pub async fn set_registry<S>(
|
||||
&self,
|
||||
keys: &Keys,
|
||||
keys: &S,
|
||||
community_id: &CommunityId,
|
||||
creator: &PublicKey,
|
||||
links: &[PublicKey],
|
||||
head: Option<&EntityHead>,
|
||||
citation: Option<AuthorityCitation>,
|
||||
at_secs: u64,
|
||||
) -> Result<(Event, EntityHead)> {
|
||||
) -> Result<(Event, EntityHead)>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
let entries: Vec<String> = links
|
||||
.iter()
|
||||
.take(MAX_REGISTRY_LINKS)
|
||||
@@ -397,20 +432,24 @@ impl ControlWriter {
|
||||
},
|
||||
at_secs,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// The whole Pin List, in whichever of CORD-04 §7's two forms the Channel calls for.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn set_pin_list(
|
||||
pub async fn set_pin_list<S>(
|
||||
&self,
|
||||
keys: &Keys,
|
||||
keys: &S,
|
||||
community_id: &CommunityId,
|
||||
channel: &ChannelId,
|
||||
content: &str,
|
||||
head: Option<&EntityHead>,
|
||||
citation: Option<AuthorityCitation>,
|
||||
at_secs: u64,
|
||||
) -> Result<(Event, EntityHead)> {
|
||||
) -> Result<(Event, EntityHead)>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
self.publish(
|
||||
keys,
|
||||
Edition {
|
||||
@@ -422,6 +461,7 @@ impl ControlWriter {
|
||||
},
|
||||
at_secs,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -708,14 +748,17 @@ fn authorized_head<'a>(
|
||||
selection.head.map(|index| authorized[index])
|
||||
}
|
||||
|
||||
fn seal_edition(
|
||||
async fn seal_edition<S>(
|
||||
edition: &UnsignedEvent,
|
||||
owner: &Keys,
|
||||
owner: &S,
|
||||
read: &GroupKey,
|
||||
signer: &GroupKey,
|
||||
at_secs: u64,
|
||||
) -> Result<Event> {
|
||||
let seal = build_seal(edition, SealForm::Plaintext, read, owner)?;
|
||||
) -> Result<Event>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
let seal = build_seal(edition, SealForm::Plaintext, read, owner).await?;
|
||||
|
||||
let (wrap, _) = wrap_seal_with(
|
||||
&seal,
|
||||
@@ -731,15 +774,14 @@ fn seal_edition(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use nostr_memory::MemoryDatabase;
|
||||
use nostr_sdk::prelude::Keys;
|
||||
|
||||
use super::*;
|
||||
use crate::cord03::{self, build_message, seal_rumor};
|
||||
use crate::cord04::fold;
|
||||
use crate::cord04::pins;
|
||||
use crate::cord04::roles::{Grant, MAX_BANLIST, MAX_ROLES_PER_MEMBER, Role, RoleScope};
|
||||
use crate::derive::{channel_group_key, grant_locator};
|
||||
use crate::store::{CommunityState, load_state, save_state};
|
||||
use crate::store::CommunityState;
|
||||
use crate::{Extra, RoleId};
|
||||
|
||||
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]
|
||||
fn metadata_and_channel_edits_reach_a_second_client() {
|
||||
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 owner_pk = owner.public_key();
|
||||
let (read, signer) = holder(&minted);
|
||||
@@ -860,33 +842,31 @@ mod tests {
|
||||
.get(minted.channel_id.as_bytes())
|
||||
.expect("head");
|
||||
|
||||
let (community_wrap, _) = writer
|
||||
.set_community_metadata(
|
||||
&owner,
|
||||
&community_id,
|
||||
&CommunityMetadata {
|
||||
relays: vec!["wss://relay.example".to_owned()],
|
||||
..metadata("coop two")
|
||||
},
|
||||
Some(community_head),
|
||||
None,
|
||||
AT + 1,
|
||||
)
|
||||
.expect("publishes");
|
||||
let (channel_wrap, _) = writer
|
||||
.set_channel_metadata(
|
||||
&owner,
|
||||
&minted.channel_id,
|
||||
&ChannelMetadata {
|
||||
name: "lobby".to_owned(),
|
||||
private: false,
|
||||
..ChannelMetadata::default()
|
||||
},
|
||||
Some(channel_head),
|
||||
None,
|
||||
AT + 2,
|
||||
)
|
||||
.expect("publishes");
|
||||
let (community_wrap, _) = smol::block_on(writer.set_community_metadata(
|
||||
&owner,
|
||||
&community_id,
|
||||
&CommunityMetadata {
|
||||
relays: vec!["wss://relay.example".to_owned()],
|
||||
..metadata("coop two")
|
||||
},
|
||||
Some(community_head),
|
||||
None,
|
||||
AT + 1,
|
||||
))
|
||||
.expect("publishes");
|
||||
let (channel_wrap, _) = smol::block_on(writer.set_channel_metadata(
|
||||
&owner,
|
||||
&minted.channel_id,
|
||||
&ChannelMetadata {
|
||||
name: "lobby".to_owned(),
|
||||
private: false,
|
||||
..ChannelMetadata::default()
|
||||
},
|
||||
Some(channel_head),
|
||||
None,
|
||||
AT + 2,
|
||||
))
|
||||
.expect("publishes");
|
||||
|
||||
let mut edited = genesis_editions.clone();
|
||||
edited.extend(open_all(
|
||||
@@ -938,7 +918,7 @@ mod tests {
|
||||
fn a_delegated_member_edits_metadata_only_under_its_own_grant() {
|
||||
let owner = 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 owner_pk = owner.public_key();
|
||||
let (read, signer) = holder(&minted);
|
||||
@@ -959,21 +939,20 @@ mod tests {
|
||||
extra: Extra::default(),
|
||||
};
|
||||
|
||||
let (role_wrap, _) = writer
|
||||
.publish(
|
||||
&owner,
|
||||
Edition {
|
||||
subkind: vsk::ROLE,
|
||||
entity: *role_id.as_bytes(),
|
||||
content: &role.to_content().expect("serializes"),
|
||||
head: None,
|
||||
citation: None,
|
||||
},
|
||||
AT + 1,
|
||||
)
|
||||
.expect("publishes");
|
||||
let (grant_wrap, _) = writer
|
||||
.publish(
|
||||
let (role_wrap, _) = smol::block_on(writer.publish(
|
||||
&owner,
|
||||
Edition {
|
||||
subkind: vsk::ROLE,
|
||||
entity: *role_id.as_bytes(),
|
||||
content: &role.to_content().expect("serializes"),
|
||||
head: None,
|
||||
citation: None,
|
||||
},
|
||||
AT + 1,
|
||||
))
|
||||
.expect("publishes");
|
||||
let (grant_wrap, _) = smol::block_on(
|
||||
writer.publish(
|
||||
&owner,
|
||||
Edition {
|
||||
subkind: vsk::GRANT,
|
||||
@@ -990,8 +969,9 @@ mod tests {
|
||||
citation: None,
|
||||
},
|
||||
AT + 2,
|
||||
)
|
||||
.expect("publishes");
|
||||
),
|
||||
)
|
||||
.expect("publishes");
|
||||
|
||||
let mut base = open_all(&minted.wraps, &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 (uncited, _) = member_writer
|
||||
.publish(
|
||||
&member,
|
||||
Edition {
|
||||
subkind: vsk::COMMUNITY_METADATA,
|
||||
entity: *community_id.as_bytes(),
|
||||
content: &content,
|
||||
head: Some(head),
|
||||
citation: None,
|
||||
},
|
||||
AT + 3,
|
||||
)
|
||||
.expect("publishes");
|
||||
let (cited, _) = member_writer
|
||||
.publish(
|
||||
&member,
|
||||
Edition {
|
||||
subkind: vsk::COMMUNITY_METADATA,
|
||||
entity: *community_id.as_bytes(),
|
||||
content: &content,
|
||||
head: Some(head),
|
||||
citation: Some(AuthorityCitation {
|
||||
entity: grant.entity,
|
||||
version: grant.version,
|
||||
hash: grant.self_hash,
|
||||
}),
|
||||
},
|
||||
AT + 4,
|
||||
)
|
||||
.expect("publishes");
|
||||
let (uncited, _) = smol::block_on(member_writer.publish(
|
||||
&member,
|
||||
Edition {
|
||||
subkind: vsk::COMMUNITY_METADATA,
|
||||
entity: *community_id.as_bytes(),
|
||||
content: &content,
|
||||
head: Some(head),
|
||||
citation: None,
|
||||
},
|
||||
AT + 3,
|
||||
))
|
||||
.expect("publishes");
|
||||
let (cited, _) = smol::block_on(member_writer.publish(
|
||||
&member,
|
||||
Edition {
|
||||
subkind: vsk::COMMUNITY_METADATA,
|
||||
entity: *community_id.as_bytes(),
|
||||
content: &content,
|
||||
head: Some(head),
|
||||
citation: Some(AuthorityCitation {
|
||||
entity: grant.entity,
|
||||
version: grant.version,
|
||||
hash: grant.self_hash,
|
||||
}),
|
||||
},
|
||||
AT + 4,
|
||||
))
|
||||
.expect("publishes");
|
||||
|
||||
// Uncited, the edit claims an authority the member never showed.
|
||||
let mut forged = base.clone();
|
||||
@@ -1086,7 +1064,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_pin_list_folds_under_its_coordinate_for_a_second_client() {
|
||||
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 owner_pk = owner.public_key();
|
||||
let (read, signer) = holder(&minted);
|
||||
@@ -1105,7 +1083,7 @@ mod tests {
|
||||
AT * 1_000,
|
||||
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)
|
||||
.expect("opens")
|
||||
.0;
|
||||
@@ -1127,17 +1105,16 @@ mod tests {
|
||||
read: read.clone(),
|
||||
signer: signer.clone(),
|
||||
};
|
||||
let (pin_wrap, _) = writer
|
||||
.set_pin_list(
|
||||
&owner,
|
||||
&community_id,
|
||||
&channel,
|
||||
&content,
|
||||
None,
|
||||
None,
|
||||
AT + 1,
|
||||
)
|
||||
.expect("publishes");
|
||||
let (pin_wrap, _) = smol::block_on(writer.set_pin_list(
|
||||
&owner,
|
||||
&community_id,
|
||||
&channel,
|
||||
&content,
|
||||
None,
|
||||
None,
|
||||
AT + 1,
|
||||
))
|
||||
.expect("publishes");
|
||||
|
||||
let mut editions = open_all(&minted.wraps, &read, &signer.pk());
|
||||
editions.extend(open_all(&[pin_wrap], &read, &signer.pk()));
|
||||
@@ -1172,7 +1149,7 @@ mod tests {
|
||||
#[test]
|
||||
fn the_timer_is_never_guessed_and_the_write_caps_hold() {
|
||||
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 owner_pk = owner.public_key();
|
||||
let (read, signer) = holder(&minted);
|
||||
@@ -1187,10 +1164,16 @@ mod tests {
|
||||
&owner_pk,
|
||||
&community_id,
|
||||
&open_all(
|
||||
&[writer
|
||||
.set_community_metadata(&owner, &community_id, metadata, None, None, AT + 1)
|
||||
.expect("publishes")
|
||||
.0],
|
||||
&[smol::block_on(writer.set_community_metadata(
|
||||
&owner,
|
||||
&community_id,
|
||||
metadata,
|
||||
None,
|
||||
None,
|
||||
AT + 1,
|
||||
))
|
||||
.expect("publishes")
|
||||
.0],
|
||||
&writer.read,
|
||||
&writer.signer.pk(),
|
||||
),
|
||||
@@ -1226,8 +1209,7 @@ mod tests {
|
||||
.map(|_| Keys::generate().public_key())
|
||||
.collect();
|
||||
assert!(
|
||||
writer
|
||||
.set_banlist(&owner, &community_id, &banned, None, None, AT + 2)
|
||||
smol::block_on(writer.set_banlist(&owner, &community_id, &banned, None, None, AT + 2))
|
||||
.is_err()
|
||||
);
|
||||
|
||||
@@ -1242,20 +1224,19 @@ mod tests {
|
||||
assert!(grant.to_content().is_err());
|
||||
|
||||
assert!(
|
||||
writer
|
||||
.set_channel_metadata(
|
||||
&owner,
|
||||
&minted.channel_id,
|
||||
&ChannelMetadata {
|
||||
name: "x".repeat(MAX_NAME_BYTES + 1),
|
||||
private: false,
|
||||
..ChannelMetadata::default()
|
||||
},
|
||||
None,
|
||||
None,
|
||||
AT + 3,
|
||||
)
|
||||
.is_err()
|
||||
smol::block_on(writer.set_channel_metadata(
|
||||
&owner,
|
||||
&minted.channel_id,
|
||||
&ChannelMetadata {
|
||||
name: "x".repeat(MAX_NAME_BYTES + 1),
|
||||
private: false,
|
||||
..ChannelMetadata::default()
|
||||
},
|
||||
None,
|
||||
None,
|
||||
AT + 3,
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
|
||||
use anyhow::Result;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::cord01::{
|
||||
KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, StreamError, build_rumor_ms,
|
||||
KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, TAG_CHANNEL, TAG_EPOCH, build_rumor_ms,
|
||||
build_seal, channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict,
|
||||
wrap_seal,
|
||||
};
|
||||
use crate::cord04::{
|
||||
AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag,
|
||||
unique_tag, wrap_seal,
|
||||
};
|
||||
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::{ChannelId, Epoch, GroupKey, decode_hex_32};
|
||||
|
||||
@@ -36,42 +35,6 @@ const TAG_TARGET_AUTHOR: &str = "p";
|
||||
const TAG_EXPIRATION: &str = "expiration";
|
||||
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.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ReplyRef {
|
||||
@@ -292,19 +255,22 @@ pub fn build_typing(
|
||||
}
|
||||
|
||||
/// `ephemeral` picks the 21059 wrap, which relays must not store.
|
||||
pub fn seal_rumor(
|
||||
pub async fn seal_rumor<S>(
|
||||
rumor: &UnsignedEvent,
|
||||
group: &GroupKey,
|
||||
author: &Keys,
|
||||
author: &S,
|
||||
ephemeral: bool,
|
||||
) -> Result<(Event, Keys), ChatError> {
|
||||
) -> Result<(Event, Keys), ChatError>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
let kind = rumor.kind.as_u16();
|
||||
|
||||
if !is_chat_kind(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 {
|
||||
KIND_WRAP_EPHEMERAL
|
||||
} else {
|
||||
@@ -348,6 +314,20 @@ pub fn open(
|
||||
Ok((opened, chat))
|
||||
}
|
||||
|
||||
/// Rebuild a rumor from a locally-cached copy: the binding tags name its channel and epoch.
|
||||
pub fn parse_rumor(rumor: &UnsignedEvent) -> Result<ChatRumor, ChatError> {
|
||||
let channel: ChannelId = unique_tag(rumor, TAG_CHANNEL)?
|
||||
.ok_or(ChatError::MissingTag(TAG_CHANNEL))?
|
||||
.parse()
|
||||
.map_err(|_| ChatError::BadTag(TAG_CHANNEL))?;
|
||||
|
||||
let epoch = unique_tag(rumor, TAG_EPOCH)?
|
||||
.ok_or(ChatError::MissingTag(TAG_EPOCH))
|
||||
.and_then(|raw| canonical_decimal(&raw).ok_or(ChatError::BadTag(TAG_EPOCH)))?;
|
||||
|
||||
typed(rumor, &channel, Epoch(epoch))
|
||||
}
|
||||
|
||||
/// `secret` is the `community_root` for a public channel, its own key for a private one.
|
||||
pub fn plane_keys(
|
||||
held: &[(Epoch, [u8; 32])],
|
||||
@@ -576,16 +556,6 @@ fn optional_kind(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<u16
|
||||
.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> {
|
||||
let Some(fields) = tag(rumor, TAG_EXPIRATION)? else {
|
||||
return Ok(None);
|
||||
@@ -611,51 +581,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> {
|
||||
let bytes = decode_hex_32(value(fields, name)?).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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cord01::StreamError;
|
||||
|
||||
const SECRET: [u8; 32] = [0x2du8; 32];
|
||||
const AT: u64 = 1_700_000_000_417;
|
||||
@@ -674,7 +609,9 @@ mod tests {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -949,7 +886,8 @@ mod tests {
|
||||
|
||||
// Chat is encrypted-seal only (CORD-02 §5), and a retired kind is not a
|
||||
// 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(
|
||||
&seal,
|
||||
&group,
|
||||
@@ -971,7 +909,7 @@ mod tests {
|
||||
AT,
|
||||
);
|
||||
assert!(matches!(
|
||||
seal_rumor(&ghost, &group, &alice, false),
|
||||
smol::block_on(seal_rumor(&ghost, &group, &alice, false)),
|
||||
Err(ChatError::UnknownKind(3300))
|
||||
));
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ fn signing_bytes(
|
||||
bytes
|
||||
}
|
||||
|
||||
pub fn edition_hash(
|
||||
fn edition_hash(
|
||||
entity: &[u8; 32],
|
||||
version: u64,
|
||||
prev: Option<&[u8; 32]>,
|
||||
@@ -246,14 +246,14 @@ impl From<&ParsedEdition> for EditionMeta {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct FoldResult {
|
||||
struct FoldResult {
|
||||
pub head: Option<usize>,
|
||||
pub gap: bool,
|
||||
pub anchored: bool,
|
||||
}
|
||||
|
||||
/// 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();
|
||||
|
||||
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.
|
||||
pub fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> {
|
||||
fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> {
|
||||
editions
|
||||
.iter()
|
||||
.enumerate()
|
||||
|
||||
@@ -583,7 +583,7 @@ mod tests {
|
||||
at_ms,
|
||||
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")
|
||||
}
|
||||
@@ -699,7 +699,7 @@ mod tests {
|
||||
AT_MS + 5_000,
|
||||
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 refreshed = with_proven_edit(&entry, &edit_opened, &group(), &channel());
|
||||
@@ -718,7 +718,8 @@ mod tests {
|
||||
AT_MS + 6_000,
|
||||
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 unchanged = with_proven_edit(&entry, &hijack_opened, &group(), &channel());
|
||||
@@ -805,7 +806,8 @@ mod tests {
|
||||
None,
|
||||
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");
|
||||
|
||||
assert_eq!(
|
||||
|
||||
@@ -100,7 +100,7 @@ pub struct Role {
|
||||
}
|
||||
|
||||
impl Role {
|
||||
pub fn parse(content: &str) -> Option<Self> {
|
||||
fn parse(content: &str) -> Option<Self> {
|
||||
serde_json::from_str(content).ok()
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ pub struct Grant {
|
||||
}
|
||||
|
||||
impl Grant {
|
||||
pub fn parse(content: &str) -> Option<Self> {
|
||||
fn parse(content: &str) -> Option<Self> {
|
||||
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 mut banned = Vec::with_capacity(entries.len());
|
||||
|
||||
|
||||
@@ -11,10 +11,10 @@ use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cord01::{self, NIP44_MAX_PLAINTEXT, StreamError};
|
||||
use crate::cord02::list::{canonical, union};
|
||||
use crate::cord02::{ImageRef, MAX_RELAYS};
|
||||
use crate::cord04::{TAG_SUBKIND, vsk};
|
||||
use crate::derive::{TOKEN_LEN, verify_community_id};
|
||||
use crate::utils::{canonical, union};
|
||||
use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32};
|
||||
|
||||
pub const KIND_BUNDLE: u16 = 33301;
|
||||
@@ -447,16 +447,19 @@ pub fn parse_link(input: &str) -> Result<ParsedInviteLink, InviteError> {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_direct_invite(
|
||||
inviter: &Keys,
|
||||
pub async fn build_direct_invite<S>(
|
||||
inviter: &S,
|
||||
recipient: &PublicKey,
|
||||
invite: &CommunityInvite,
|
||||
) -> Result<Event, InviteError> {
|
||||
) -> Result<Event, InviteError>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44,
|
||||
{
|
||||
invite.validate()?;
|
||||
|
||||
let json = serde_json::to_string(invite).map_err(json_error)?;
|
||||
let rumor = EventBuilder::new(Kind::Custom(KIND_DIRECT_INVITE), json)
|
||||
.finalize_unsigned(inviter.public_key());
|
||||
let author = inviter.get_public_key_async().await.map_err(crypto_error)?;
|
||||
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()])];
|
||||
|
||||
@@ -469,15 +472,22 @@ pub fn build_direct_invite(
|
||||
|
||||
GiftWrapBuilder::new(*recipient, rumor)
|
||||
.extra_tags(tags)
|
||||
.finalize(inviter)
|
||||
.finalize_async(inviter)
|
||||
.await
|
||||
.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,
|
||||
recipient: &Keys,
|
||||
) -> Result<(PublicKey, CommunityInvite), InviteError> {
|
||||
let unwrapped = UnwrappedGift::from_gift_wrap(recipient, wrap).map_err(crypto_error)?;
|
||||
recipient: &S,
|
||||
) -> Result<(PublicKey, CommunityInvite), InviteError>
|
||||
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 {
|
||||
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()?;
|
||||
|
||||
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)
|
||||
.finalize(keys)
|
||||
.finalize_async(keys)
|
||||
.await
|
||||
.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 {
|
||||
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.
|
||||
@@ -920,7 +937,12 @@ mod tests {
|
||||
let recipient = Keys::generate();
|
||||
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_ne!(
|
||||
wrap.pubkey,
|
||||
@@ -932,13 +954,14 @@ mod tests {
|
||||
"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!(opened.community_id, invite.community_id);
|
||||
|
||||
// Somebody else's wrap is not ours to open...
|
||||
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.
|
||||
let rumor = EventBuilder::new(Kind::Custom(crate::cord03::KIND_MESSAGE), "hello")
|
||||
@@ -947,7 +970,7 @@ mod tests {
|
||||
.finalize(&recipient)
|
||||
.expect("wraps");
|
||||
assert!(matches!(
|
||||
unwrap_direct_invite(&wrap, &recipient),
|
||||
smol::block_on(unwrap_direct_invite(&wrap, &recipient)),
|
||||
Err(InviteError::BadEvent(_))
|
||||
));
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
|
||||
use anyhow::Result;
|
||||
use data_encoding::HEXLOWER;
|
||||
use nostr::nips::nip44::v2::ConversationKey;
|
||||
use nostr_sdk::prelude::{Event, Keys, PublicKey, SecretKey, Tag, Timestamp, UnsignedEvent};
|
||||
use data_encoding::{BASE64, HEXLOWER};
|
||||
use nostr_sdk::prelude::{
|
||||
AsyncGetPublicKey, AsyncNip44, AsyncSignEvent, Event, PublicKey, Tag, Timestamp, UnsignedEvent,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cord01::{self, KIND_SEAL_PLAINTEXT, OpenedStream, SealForm, StreamError};
|
||||
@@ -298,34 +299,48 @@ pub fn blob_locator(
|
||||
))
|
||||
}
|
||||
|
||||
pub fn build_blob(
|
||||
rotator: &Keys,
|
||||
pub async fn build_blob<S>(
|
||||
rotator: &S,
|
||||
recipient: &PublicKey,
|
||||
scope: RekeyScope,
|
||||
epoch: Epoch,
|
||||
new_key: &[u8; 32],
|
||||
control_pk: 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 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 {
|
||||
locator: blob_locator(&rotator.public_key(), recipient, scope, epoch),
|
||||
wrapped: seal_to(rotator.secret_key(), recipient, &plaintext)?,
|
||||
locator: blob_locator(&rotator_pk, recipient, scope, epoch),
|
||||
wrapped,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn open_blob(
|
||||
recipient: &Keys,
|
||||
pub async fn open_blob<S>(
|
||||
recipient: &S,
|
||||
rotator: &PublicKey,
|
||||
scope: RekeyScope,
|
||||
epoch: Epoch,
|
||||
blob: &RekeyBlob,
|
||||
community_id: &CommunityId,
|
||||
) -> Result<KeyDelivery, RekeyError> {
|
||||
let conversation =
|
||||
ConversationKey::derive(recipient.secret_key(), rotator).map_err(crypto_error)?;
|
||||
let plaintext = cord01::open_bytes(&conversation, &blob.wrapped)?;
|
||||
) -> Result<KeyDelivery, RekeyError>
|
||||
where
|
||||
S: AsyncNip44 + ?Sized,
|
||||
{
|
||||
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)
|
||||
}
|
||||
@@ -341,15 +356,6 @@ pub fn find_my_blobs<'a>(
|
||||
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)]
|
||||
pub struct RekeyChunk {
|
||||
pub rotator: PublicKey,
|
||||
@@ -598,8 +604,8 @@ pub fn build_rekey_rumor(
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build_rekey_chunks(
|
||||
rotator: &Keys,
|
||||
pub async fn build_rekey_chunks<S>(
|
||||
rotator: &S,
|
||||
group: &GroupKey,
|
||||
scope: RekeyScope,
|
||||
new_epoch: Epoch,
|
||||
@@ -609,7 +615,11 @@ pub fn build_rekey_chunks(
|
||||
citation: Option<&AuthorityCitation>,
|
||||
severed: bool,
|
||||
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();
|
||||
|
||||
if groups.is_empty() {
|
||||
@@ -621,7 +631,7 @@ pub fn build_rekey_chunks(
|
||||
|
||||
for (index, group_blobs) in groups.into_iter().enumerate() {
|
||||
let rumor = build_rekey_rumor(
|
||||
rotator.public_key(),
|
||||
rotator_key,
|
||||
scope,
|
||||
new_epoch,
|
||||
prev_epoch,
|
||||
@@ -633,7 +643,7 @@ pub fn build_rekey_chunks(
|
||||
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(
|
||||
&seal,
|
||||
group,
|
||||
@@ -731,14 +741,17 @@ pub fn dissolved_tombstone_rumor(
|
||||
)
|
||||
}
|
||||
|
||||
pub fn seal_dissolved(
|
||||
pub async fn seal_dissolved<S>(
|
||||
rumor: &UnsignedEvent,
|
||||
community_id: &CommunityId,
|
||||
owner: &Keys,
|
||||
owner: &S,
|
||||
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 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(
|
||||
&seal,
|
||||
&group,
|
||||
@@ -858,6 +871,8 @@ fn crypto_error(error: impl fmt::Display) -> RekeyError {
|
||||
mod tests {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use nostr_sdk::prelude::Keys;
|
||||
|
||||
use super::*;
|
||||
use crate::cord01::KIND_WRAP;
|
||||
use crate::cord02::{
|
||||
@@ -912,16 +927,16 @@ mod tests {
|
||||
let scope = RekeyScope::Channel(channel());
|
||||
|
||||
let open = |keys: &Keys, scope: RekeyScope, epoch: Epoch, blob: &RekeyBlob| {
|
||||
open_blob(
|
||||
smol::block_on(open_blob(
|
||||
keys,
|
||||
&rotator.public_key(),
|
||||
scope,
|
||||
epoch,
|
||||
blob,
|
||||
&community_id,
|
||||
)
|
||||
))
|
||||
};
|
||||
let blob = build_blob(
|
||||
let blob = smol::block_on(build_blob(
|
||||
&rotator,
|
||||
&recipient.public_key(),
|
||||
scope,
|
||||
@@ -929,7 +944,7 @@ mod tests {
|
||||
&key,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
))
|
||||
.expect("builds");
|
||||
|
||||
assert_eq!(
|
||||
@@ -962,7 +977,7 @@ mod tests {
|
||||
.to_bytes();
|
||||
|
||||
let base = |pk: Option<&[u8; 32]>, root: Option<&[u8; 32]>| {
|
||||
build_blob(
|
||||
smol::block_on(build_blob(
|
||||
&rotator,
|
||||
&recipient.public_key(),
|
||||
RekeyScope::Base,
|
||||
@@ -970,7 +985,7 @@ mod tests {
|
||||
&key,
|
||||
pk,
|
||||
root,
|
||||
)
|
||||
))
|
||||
.expect("builds")
|
||||
};
|
||||
|
||||
@@ -1043,7 +1058,7 @@ mod tests {
|
||||
let community_id = community();
|
||||
|
||||
let blob_for = |recipient: &Keys, key: [u8; 32]| {
|
||||
build_blob(
|
||||
smol::block_on(build_blob(
|
||||
&rotator,
|
||||
&recipient.public_key(),
|
||||
scope,
|
||||
@@ -1051,7 +1066,7 @@ mod tests {
|
||||
&key,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
))
|
||||
.expect("builds")
|
||||
};
|
||||
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 prior_commit = epoch_key_commitment(Epoch(0), &PRIOR_KEY);
|
||||
let chunks = build_rekey_chunks(
|
||||
let chunks = smol::block_on(build_rekey_chunks(
|
||||
&rotator,
|
||||
&group,
|
||||
scope,
|
||||
@@ -1070,7 +1085,7 @@ mod tests {
|
||||
None,
|
||||
false,
|
||||
AT,
|
||||
)
|
||||
))
|
||||
.expect("builds");
|
||||
assert_eq!(chunks.len(), 1);
|
||||
|
||||
@@ -1129,14 +1144,14 @@ mod tests {
|
||||
.next()
|
||||
.expect("located");
|
||||
assert_eq!(
|
||||
open_blob(
|
||||
smol::block_on(open_blob(
|
||||
&me,
|
||||
&rotator.public_key(),
|
||||
scope,
|
||||
epoch,
|
||||
located,
|
||||
&community_id
|
||||
)
|
||||
))
|
||||
.expect("opens")
|
||||
.new_key,
|
||||
[0xAA; 32]
|
||||
@@ -1269,26 +1284,26 @@ mod tests {
|
||||
content: String,
|
||||
at_secs: u64,
|
||||
) -> Event {
|
||||
writer
|
||||
.publish(
|
||||
owner,
|
||||
Edition {
|
||||
subkind,
|
||||
entity,
|
||||
content: &content,
|
||||
head: None,
|
||||
citation: None,
|
||||
},
|
||||
at_secs,
|
||||
)
|
||||
.expect("publishes")
|
||||
.0
|
||||
smol::block_on(writer.publish(
|
||||
owner,
|
||||
Edition {
|
||||
subkind,
|
||||
entity,
|
||||
content: &content,
|
||||
head: None,
|
||||
citation: None,
|
||||
},
|
||||
at_secs,
|
||||
))
|
||||
.expect("publishes")
|
||||
.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rotation_needs_the_permission_and_must_strictly_outrank_every_target() {
|
||||
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 read =
|
||||
control_group_key(&minted.community_root, &community_id, ROOT_EPOCH).expect("derives");
|
||||
@@ -1413,7 +1428,7 @@ mod tests {
|
||||
.map(|_| {
|
||||
let member = Keys::generate();
|
||||
|
||||
build_blob(
|
||||
smol::block_on(build_blob(
|
||||
&rotator,
|
||||
&member.public_key(),
|
||||
scope,
|
||||
@@ -1421,12 +1436,12 @@ mod tests {
|
||||
&[0xCD; 32],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
))
|
||||
.expect("builds")
|
||||
})
|
||||
.collect();
|
||||
|
||||
let chunks = build_rekey_chunks(
|
||||
let chunks = smol::block_on(build_rekey_chunks(
|
||||
&rotator,
|
||||
&group,
|
||||
scope,
|
||||
@@ -1437,7 +1452,7 @@ mod tests {
|
||||
None,
|
||||
false,
|
||||
AT,
|
||||
)
|
||||
))
|
||||
.expect("builds");
|
||||
|
||||
assert_eq!(chunks.len(), 1, "a full send chunk is one event");
|
||||
@@ -1452,7 +1467,7 @@ mod tests {
|
||||
wrapped: "x".to_owned(),
|
||||
});
|
||||
|
||||
let chunks = build_rekey_chunks(
|
||||
let chunks = smol::block_on(build_rekey_chunks(
|
||||
&rotator,
|
||||
&group,
|
||||
scope,
|
||||
@@ -1463,7 +1478,7 @@ mod tests {
|
||||
None,
|
||||
false,
|
||||
AT,
|
||||
)
|
||||
))
|
||||
.expect("builds");
|
||||
|
||||
assert_eq!(chunks.len(), 2, "one over the cap splits across two events");
|
||||
@@ -1485,8 +1500,13 @@ mod tests {
|
||||
content: "{}",
|
||||
at_secs: AT,
|
||||
});
|
||||
let seal =
|
||||
cord01::build_seal(&rumor, SealForm::Plaintext, &prior_read, &owner).expect("seals");
|
||||
let seal = smol::block_on(cord01::build_seal(
|
||||
&rumor,
|
||||
SealForm::Plaintext,
|
||||
&prior_read,
|
||||
&owner,
|
||||
))
|
||||
.expect("seals");
|
||||
|
||||
let refounding = plan_refounding(Epoch(1)).expect("plans");
|
||||
let read = refounding.read(&community_id).expect("derives");
|
||||
@@ -1507,8 +1527,13 @@ mod tests {
|
||||
assert_eq!(reopened.author, owner.public_key());
|
||||
|
||||
// Only a plaintext seal can be carried forward.
|
||||
let encrypted =
|
||||
cord01::build_seal(&rumor, SealForm::Encrypted, &prior_read, &owner).expect("seals");
|
||||
let encrypted = smol::block_on(cord01::build_seal(
|
||||
&rumor,
|
||||
SealForm::Encrypted,
|
||||
&prior_read,
|
||||
&owner,
|
||||
))
|
||||
.expect("seals");
|
||||
assert!(matches!(
|
||||
compact(&[encrypted], &read, &signer, AT + 1),
|
||||
Err(RekeyError::Stream(StreamError::NotRewrappable))
|
||||
@@ -1527,7 +1552,8 @@ mod tests {
|
||||
};
|
||||
|
||||
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_eq!(
|
||||
@@ -1538,18 +1564,18 @@ mod tests {
|
||||
// Anyone holding the community id finds the address, but only the committed
|
||||
// owner's signature counts.
|
||||
let impostor = Keys::generate();
|
||||
let forged = seal_dissolved(
|
||||
let forged = smol::block_on(seal_dissolved(
|
||||
&dissolved_tombstone_rumor(impostor.public_key(), &community_id, AT),
|
||||
&community_id,
|
||||
&impostor,
|
||||
AT,
|
||||
)
|
||||
))
|
||||
.expect("seals");
|
||||
assert!(!verify_dissolved(&forged, &identity));
|
||||
|
||||
// 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.
|
||||
let zeroed = seal_dissolved(
|
||||
let zeroed = smol::block_on(seal_dissolved(
|
||||
&cord01::build_rumor_secs(
|
||||
KIND_CONTROL,
|
||||
owner.public_key(),
|
||||
@@ -1563,7 +1589,7 @@ mod tests {
|
||||
&community_id,
|
||||
&owner,
|
||||
AT,
|
||||
)
|
||||
))
|
||||
.expect("seals");
|
||||
assert!(matches!(
|
||||
open_dissolved(&zeroed, &community_id),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//! One module per CORD document. CORD-07 (audio/video) is unimplemented, and
|
||||
//! CORD-08's timer rides the Chat and Control planes it edits rather than owning a file.
|
||||
mod rumor;
|
||||
|
||||
pub mod cord01;
|
||||
pub mod cord02;
|
||||
|
||||
@@ -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
@@ -3,17 +3,20 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use data_encoding::HEXLOWER;
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cord01::{KIND_WRAP_EPHEMERAL, OpenedStream};
|
||||
use crate::cord02::list::{CommunityListEntry, JoinMaterial};
|
||||
use crate::cord02::{
|
||||
ChannelMetadata, CommunityGenesis, CommunityMetadata, ControlFold, ROOT_EPOCH,
|
||||
};
|
||||
use crate::cord03::{self, ChatRumor, plane_keys};
|
||||
use crate::cord04::{EntityHead, Floors, ParsedEdition, vsk};
|
||||
use crate::cord05::ChannelGrant;
|
||||
use crate::derive::control_signer_group_key;
|
||||
use crate::{ChannelId, CommunityId, Epoch, GroupKey};
|
||||
use crate::{ChannelId, CommunityId, Epoch, Extra, GroupKey, decode_hex_32};
|
||||
|
||||
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 WRAP_TAG: &str = "e";
|
||||
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.
|
||||
pub async fn cache_rumor(
|
||||
database: &dyn NostrDatabase,
|
||||
client: &Client,
|
||||
channel: &ChannelId,
|
||||
opened: &OpenedStream,
|
||||
) -> Result<bool> {
|
||||
let at = Timestamp::from_secs(opened.at_ms / 1000);
|
||||
|
||||
if cord03::expiration_of(&opened.rumor)?
|
||||
.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::public_key(opened.author),
|
||||
];
|
||||
let at = Timestamp::from_secs(opened.at_ms / 1000);
|
||||
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, opened.rumor.as_json())
|
||||
.tags(tags)
|
||||
.custom_created_at(at)
|
||||
.finalize_async(&*LOCAL_KEYS)
|
||||
.await?;
|
||||
|
||||
database.save_event(&event).await?;
|
||||
client.database().save_event(&event).await?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn purge_expired(
|
||||
database: &dyn NostrDatabase,
|
||||
channel: &ChannelId,
|
||||
now: Timestamp,
|
||||
) -> Result<usize> {
|
||||
pub async fn purge_expired(client: &Client, channel: &ChannelId, now: Timestamp) -> Result<usize> {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(MARK_TAG, MARK_VALUE)
|
||||
@@ -69,7 +71,7 @@ pub async fn purge_expired(
|
||||
|
||||
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 {
|
||||
continue;
|
||||
};
|
||||
@@ -86,14 +88,14 @@ pub async fn purge_expired(
|
||||
let purged = expired.len();
|
||||
|
||||
if purged > 0 {
|
||||
database.delete(Filter::new().ids(expired)).await?;
|
||||
client.database().delete(Filter::new().ids(expired)).await?;
|
||||
}
|
||||
|
||||
Ok(purged)
|
||||
}
|
||||
|
||||
pub async fn query_rumors(
|
||||
database: &dyn NostrDatabase,
|
||||
client: &Client,
|
||||
channel: &ChannelId,
|
||||
until: Option<Timestamp>,
|
||||
limit: usize,
|
||||
@@ -108,7 +110,7 @@ pub async fn query_rumors(
|
||||
}
|
||||
|
||||
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 {
|
||||
continue;
|
||||
};
|
||||
@@ -141,12 +143,19 @@ pub struct ChannelKeyRef {
|
||||
pub name: String,
|
||||
pub private: bool,
|
||||
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>`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CommunityState {
|
||||
pub id: CommunityId,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
pub owner: PublicKey,
|
||||
pub owner_salt: [u8; 32],
|
||||
pub community_root: [u8; 32],
|
||||
@@ -176,6 +185,7 @@ impl CommunityState {
|
||||
let mut channels = Vec::new();
|
||||
let mut heads = Vec::with_capacity(editions.len());
|
||||
let mut relays = Vec::new();
|
||||
let mut name = None;
|
||||
|
||||
for edition in editions {
|
||||
heads.push(EntityHead {
|
||||
@@ -194,6 +204,7 @@ impl CommunityState {
|
||||
.iter()
|
||||
.filter_map(|relay| RelayUrl::parse(relay).ok()),
|
||||
);
|
||||
name = label(&metadata.name);
|
||||
}
|
||||
vsk::CHANNEL_METADATA => {
|
||||
let metadata: ChannelMetadata = serde_json::from_str(&edition.content)?;
|
||||
@@ -202,6 +213,7 @@ impl CommunityState {
|
||||
name: metadata.name,
|
||||
private: metadata.private,
|
||||
epoch: ROOT_EPOCH,
|
||||
key: None,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
@@ -220,6 +232,7 @@ impl CommunityState {
|
||||
|
||||
Ok(Self {
|
||||
id: genesis.identity.community_id,
|
||||
name,
|
||||
owner: genesis.identity.owner,
|
||||
owner_salt: genesis.identity.owner_salt,
|
||||
community_root: genesis.community_root,
|
||||
@@ -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 {
|
||||
state_identifier(&self.id)
|
||||
}
|
||||
@@ -256,6 +316,10 @@ impl CommunityState {
|
||||
.iter()
|
||||
.filter_map(|relay| RelayUrl::parse(relay).ok())
|
||||
.collect();
|
||||
|
||||
if let Some(name) = label(&community.name) {
|
||||
self.name = Some(name);
|
||||
}
|
||||
}
|
||||
|
||||
for (id, metadata) in &fold.channels {
|
||||
@@ -277,6 +341,7 @@ impl CommunityState {
|
||||
name: metadata.name.clone(),
|
||||
private: false,
|
||||
epoch: self.root_epoch,
|
||||
key: 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 {
|
||||
format!("{STATE_PREFIX}{}", id.to_hex())
|
||||
}
|
||||
|
||||
pub async fn save_state<D>(database: &D, state: &CommunityState) -> Result<()>
|
||||
where
|
||||
D: NostrDatabase,
|
||||
{
|
||||
pub async fn save_state(client: &Client, state: &CommunityState) -> Result<()> {
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
|
||||
.tags([Tag::identifier(state.identifier())])
|
||||
.finalize_async(&*LOCAL_KEYS)
|
||||
.await?;
|
||||
|
||||
database.save_event(&event).await?;
|
||||
client.database().save_event(&event).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load_state<D>(database: &D, id: &CommunityId) -> Result<Option<CommunityState>>
|
||||
where
|
||||
D: NostrDatabase,
|
||||
{
|
||||
pub async fn load_state(client: &Client, id: &CommunityId) -> Result<Option<CommunityState>> {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(state_identifier(id))
|
||||
.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)?)),
|
||||
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(
|
||||
client: &Client,
|
||||
database: &dyn NostrDatabase,
|
||||
channel: &ChannelId,
|
||||
held: &[(Epoch, [u8; 32])],
|
||||
until: Option<Timestamp>,
|
||||
@@ -342,7 +475,7 @@ pub async fn backfill(
|
||||
let (fresh, next) = advance(&page, &planes, channel, cursor, limit, &mut seen);
|
||||
|
||||
for (opened, rumor) in fresh {
|
||||
if cache_rumor(database, channel, &opened).await? {
|
||||
if cache_rumor(client, channel, &opened).await? {
|
||||
found.push(rumor);
|
||||
}
|
||||
}
|
||||
@@ -416,15 +549,11 @@ async fn fetch_page(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use nostr_memory::MemoryDatabase;
|
||||
|
||||
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::cord05::ChannelGrant;
|
||||
use crate::derive::channel_group_key;
|
||||
use crate::{Epoch, Extra};
|
||||
|
||||
const SECRET: [u8; 32] = [0x07u8; 32];
|
||||
const NEXT_SECRET: [u8; 32] = [0x11u8; 32];
|
||||
@@ -472,7 +601,11 @@ mod tests {
|
||||
at_ms,
|
||||
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();
|
||||
@@ -501,148 +634,125 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rumors_read_back_after_a_restart() {
|
||||
let database = MemoryDatabase::unbounded();
|
||||
let channel = ChannelId::from_bytes([0xabu8; 32]);
|
||||
let author = Keys::generate();
|
||||
fn from_join_material_materializes_a_subscribable_state_with_or_without_the_control_root() {
|
||||
let owner = Keys::generate().public_key();
|
||||
let control_pk = Keys::generate().public_key();
|
||||
let staff = ChannelId::from_bytes([0x9c; 32]);
|
||||
let general = ChannelId::from_bytes([0x9d; 32]);
|
||||
|
||||
smol::block_on(async {
|
||||
let group = channel_group_key(&SECRET, &channel, Epoch(0)).expect("derives");
|
||||
let material = JoinMaterial {
|
||||
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 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 state = CommunityState::from_join_material(&material, 7).expect("materializes");
|
||||
|
||||
let opened = open_wrap(&wrap, &group).expect("opens");
|
||||
cache_rumor(&database, &channel, &opened)
|
||||
.await
|
||||
.expect("caches");
|
||||
}
|
||||
assert_eq!(state.id, material.community_id);
|
||||
assert_eq!(state.owner, owner);
|
||||
assert_eq!(state.owner_salt, [0x01; 32]);
|
||||
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.
|
||||
let rumors = query_rumors(&database, &channel, None, 10)
|
||||
.await
|
||||
.expect("queries");
|
||||
assert_eq!(rumors.len(), 2, "both messages come back");
|
||||
assert_eq!(rumors[0].content, "second", "newest first");
|
||||
assert_eq!(rumors[1].content, "first");
|
||||
// A granted key lands on the channel and makes it private; a grant with
|
||||
// no key is a public channel.
|
||||
let granted = state
|
||||
.channels
|
||||
.iter()
|
||||
.find(|c| c.id == staff)
|
||||
.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 until = Timestamp::from_secs(1_500);
|
||||
let page = query_rumors(&database, &channel, Some(until), 10)
|
||||
.await
|
||||
.expect("queries");
|
||||
assert_eq!(page.len(), 1);
|
||||
assert_eq!(page[0].content, "first");
|
||||
let public = state
|
||||
.channels
|
||||
.iter()
|
||||
.find(|c| c.id == general)
|
||||
.expect("general");
|
||||
assert!(!public.private);
|
||||
assert_eq!(public.key, None);
|
||||
|
||||
let capped = query_rumors(&database, &channel, None, 1)
|
||||
.await
|
||||
.expect("queries");
|
||||
assert_eq!(capped.len(), 1);
|
||||
assert_eq!(capped[0].content, "second");
|
||||
});
|
||||
// A member who is not staff carries no control_root, but reading needs no
|
||||
// secret: the address rides in the material either way.
|
||||
let mut member = material.clone();
|
||||
member.control_root = None;
|
||||
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]
|
||||
fn an_expired_rumor_is_refused_at_ingest_and_purged_by_the_sweep() {
|
||||
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();
|
||||
|
||||
fn load_states_reads_one_document_per_community_and_ignores_other_documents() {
|
||||
smol::block_on(async {
|
||||
// A live timer is stored; one that already elapsed is refused at ingest.
|
||||
assert!(
|
||||
cache(
|
||||
&database,
|
||||
&group,
|
||||
&channel,
|
||||
&author,
|
||||
"live",
|
||||
Some(3_600),
|
||||
now
|
||||
)
|
||||
.await
|
||||
);
|
||||
assert!(
|
||||
!cache(
|
||||
&database,
|
||||
&group,
|
||||
&channel,
|
||||
&author,
|
||||
"gone",
|
||||
Some(1),
|
||||
now - 120
|
||||
)
|
||||
.await
|
||||
);
|
||||
let client = ClientBuilder::default()
|
||||
.database(nostr_memory::MemoryDatabase::unbounded())
|
||||
.build();
|
||||
|
||||
let stored = query_rumors(&database, &channel, None, 10)
|
||||
.await
|
||||
.expect("queries");
|
||||
assert_eq!(stored.len(), 1);
|
||||
assert_eq!(stored[0].content, "live");
|
||||
let state = CommunityState {
|
||||
id: CommunityId::from_bytes([0x42; 32]),
|
||||
name: Some("Anime and Manga".to_owned()),
|
||||
owner: Keys::generate().public_key(),
|
||||
owner_salt: [0x01; 32],
|
||||
community_root: [0x02; 32],
|
||||
root_epoch: Epoch(0),
|
||||
control_root: None,
|
||||
control_pks: BTreeMap::new(),
|
||||
channels: Vec::new(),
|
||||
relays: Vec::new(),
|
||||
heads: Vec::new(),
|
||||
banned: BTreeSet::new(),
|
||||
dissolved: false,
|
||||
added_at_ms: 7,
|
||||
};
|
||||
|
||||
// Hiding is not disappearing: the sweep removes the row itself,
|
||||
// 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()
|
||||
);
|
||||
save_state(&client, &state).await.expect("saves");
|
||||
|
||||
// An untimed rumor is never swept, whatever the clock says.
|
||||
assert!(cache(&database, &group, &channel, &author, "timeless", None, now).await);
|
||||
let purged = purge_expired(&database, &channel, Timestamp::from_secs(now + 86_400))
|
||||
.await
|
||||
.expect("sweeps");
|
||||
assert_eq!(purged, 0);
|
||||
// A cached rumor is also an application-specific document, but not a
|
||||
// state document, so the prefix keeps it out of the state scan.
|
||||
let other = EventBuilder::new(Kind::ApplicationSpecificData, "{}")
|
||||
.tags([Tag::identifier("deadbeef")])
|
||||
.finalize(&*LOCAL_KEYS)
|
||||
.expect("builds");
|
||||
client.database().save_event(&other).await.expect("saves");
|
||||
|
||||
let loaded = load_states(&client).await.expect("loads");
|
||||
|
||||
assert_eq!(loaded, vec![state]);
|
||||
});
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
pub mod base64url;
|
||||
pub mod derive;
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use data_encoding::HEXLOWER;
|
||||
use rand::TryRng as _;
|
||||
use rand::rngs::SysRng;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::Extra;
|
||||
|
||||
/// Uppercase and other non-canonical spellings are rejected.
|
||||
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)?;
|
||||
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
@@ -24,8 +24,8 @@ use ui::{Disableable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||
|
||||
const IDENTIFIER: &str = "coop:device";
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
DeviceRegistry::set_global(cx.new(|cx| DeviceRegistry::new(window, cx)), cx);
|
||||
pub fn init(cx: &mut App) {
|
||||
DeviceRegistry::set_global(cx.new(DeviceRegistry::new), cx);
|
||||
}
|
||||
|
||||
struct GlobalDeviceRegistry(Entity<DeviceRegistry>);
|
||||
@@ -89,7 +89,8 @@ impl DeviceRegistry {
|
||||
}
|
||||
|
||||
/// 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 settings = AppSettings::global(cx);
|
||||
|
||||
@@ -114,8 +115,10 @@ impl DeviceRegistry {
|
||||
}),
|
||||
);
|
||||
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.handle_notifications(window, cx);
|
||||
cx.defer(move |cx| {
|
||||
entity
|
||||
.update(cx, |this, cx| this.handle_notifications(cx))
|
||||
.ok();
|
||||
});
|
||||
|
||||
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 client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
@@ -168,18 +171,18 @@ impl DeviceRegistry {
|
||||
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 {
|
||||
match event.kind {
|
||||
Kind::Custom(10044) => {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_encryption(&event, cx);
|
||||
})?;
|
||||
}
|
||||
// New request event from other device
|
||||
Kind::Custom(4454) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.ask_for_approval(event, window, cx);
|
||||
this.update(cx, |this, cx| {
|
||||
this.ask_for_approval(event, cx);
|
||||
})?;
|
||||
}
|
||||
// New response event from the master device
|
||||
@@ -591,7 +594,7 @@ impl DeviceRegistry {
|
||||
}
|
||||
|
||||
/// 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
|
||||
if self.pending_request {
|
||||
return;
|
||||
@@ -600,7 +603,18 @@ impl DeviceRegistry {
|
||||
|
||||
// Show notification
|
||||
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.
|
||||
@@ -655,7 +669,11 @@ impl DeviceRegistry {
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(Avatar::new(profile.avatar()).xsmall())
|
||||
.child(
|
||||
Avatar::new(profile.avatar())
|
||||
.seed(profile.avatar_seed())
|
||||
.xsmall(),
|
||||
)
|
||||
.child(profile.name()),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::sync::RwLock;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
use common::EventExt;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Task, Window};
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Task};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
@@ -13,8 +13,8 @@ mod person;
|
||||
|
||||
pub use person::*;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
PersonRegistry::set_global(cx.new(|cx| PersonRegistry::new(window, cx)), cx);
|
||||
pub fn init(cx: &mut App) {
|
||||
PersonRegistry::set_global(cx.new(PersonRegistry::new), cx);
|
||||
}
|
||||
|
||||
struct GlobalPersonRegistry(Entity<PersonRegistry>);
|
||||
@@ -56,7 +56,8 @@ impl PersonRegistry {
|
||||
}
|
||||
|
||||
/// 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 client = nostr.read(cx).client();
|
||||
|
||||
@@ -96,8 +97,8 @@ impl PersonRegistry {
|
||||
}));
|
||||
|
||||
// Load all user profiles from the database
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.load(cx);
|
||||
cx.defer(move |cx| {
|
||||
entity.update(cx, |this, cx| this.load(cx)).ok();
|
||||
});
|
||||
|
||||
Self {
|
||||
|
||||
@@ -103,14 +103,18 @@ impl Person {
|
||||
self.messaging_relays.first().cloned()
|
||||
}
|
||||
|
||||
/// Get profile avatar
|
||||
pub fn avatar(&self) -> SharedString {
|
||||
/// Get profile picture, if the profile has one
|
||||
pub fn avatar(&self) -> Option<SharedString> {
|
||||
self.metadata()
|
||||
.picture
|
||||
.as_ref()
|
||||
.filter(|picture| !picture.is_empty())
|
||||
.map(|picture| picture.into())
|
||||
.unwrap_or_else(|| "brand/avatar.png".into())
|
||||
.map(SharedString::from)
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
+40
-16
@@ -8,8 +8,8 @@ use serde::{Deserialize, Serialize};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use theme::{Theme, ThemeFamily, ThemeMode};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
AppSettings::set_global(cx.new(|cx| AppSettings::new(window, cx)), cx)
|
||||
pub fn init(cx: &mut App) {
|
||||
AppSettings::set_global(cx.new(AppSettings::new), cx)
|
||||
}
|
||||
|
||||
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
|
||||
@@ -46,8 +46,11 @@ setting_accessors! {
|
||||
pub nip4e: bool,
|
||||
pub trusted_relays: Vec<String>,
|
||||
pub file_server: Url,
|
||||
pub recent_communities: Vec<String>,
|
||||
}
|
||||
|
||||
const RECENT_COMMUNITIES_CAP: usize = 10;
|
||||
|
||||
/// Signer kind
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum SignerKind {
|
||||
@@ -130,6 +133,10 @@ pub struct Settings {
|
||||
|
||||
/// Server for blossom media attachments
|
||||
pub file_server: Url,
|
||||
|
||||
/// Recently opened community ids, newest first
|
||||
#[serde(default)]
|
||||
pub recent_communities: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
@@ -142,6 +149,7 @@ impl Default for Settings {
|
||||
nip4e: false,
|
||||
trusted_relays: vec![],
|
||||
file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(),
|
||||
recent_communities: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,7 +168,6 @@ impl Global for GlobalAppSettings {}
|
||||
pub struct AppSettings {
|
||||
/// Settings
|
||||
inner: Entity<Settings>,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
@@ -171,12 +178,18 @@ impl AppSettings {
|
||||
cx.global::<GlobalAppSettings>().0.clone()
|
||||
}
|
||||
|
||||
/// The underlying settings entity, which notifies whenever any field changes.
|
||||
pub fn entity(&self) -> &Entity<Settings> {
|
||||
&self.inner
|
||||
}
|
||||
|
||||
/// Set the global settings instance
|
||||
fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||
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 mut subscriptions = smallvec![];
|
||||
|
||||
@@ -188,8 +201,8 @@ impl AppSettings {
|
||||
);
|
||||
|
||||
// Run at the end of current cycle
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.load(window, cx);
|
||||
cx.defer(move |cx| {
|
||||
entity.update(cx, |this, cx| this.load(cx)).ok();
|
||||
});
|
||||
|
||||
Self {
|
||||
@@ -207,7 +220,7 @@ impl AppSettings {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
@@ -219,7 +232,7 @@ impl AppSettings {
|
||||
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());
|
||||
|
||||
// Move settings still pointed at the old default file server over to the new one
|
||||
@@ -228,9 +241,10 @@ impl AppSettings {
|
||||
}
|
||||
|
||||
// Update settings
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_settings(settings, cx);
|
||||
this.apply_theme(window, cx);
|
||||
this.apply_theme(None, cx);
|
||||
cx.refresh_windows();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
@@ -262,7 +276,7 @@ impl AppSettings {
|
||||
});
|
||||
|
||||
// Apply the new theme
|
||||
self.apply_theme(window, cx);
|
||||
self.apply_theme(Some(window), cx);
|
||||
}
|
||||
|
||||
/// Reset theme
|
||||
@@ -271,22 +285,22 @@ impl AppSettings {
|
||||
this.theme = None;
|
||||
cx.notify();
|
||||
});
|
||||
self.apply_theme(window, cx);
|
||||
self.apply_theme(Some(window), cx);
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
let mode = self.inner.read(cx).theme_mode;
|
||||
|
||||
if let Ok(new_theme) = ThemeFamily::from_assets(name) {
|
||||
Theme::apply_theme(Rc::new(new_theme), Some(window), cx);
|
||||
Theme::change(mode, Some(window), cx);
|
||||
Theme::apply_theme(Rc::new(new_theme), window.as_deref_mut(), cx);
|
||||
Theme::change(mode, window, cx);
|
||||
} else {
|
||||
log::info!("Failed to load theme: {name}");
|
||||
}
|
||||
} else {
|
||||
Theme::apply_theme(Rc::new(ThemeFamily::default()), Some(window), cx);
|
||||
Theme::apply_theme(Rc::new(ThemeFamily::default()), window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,4 +332,14 @@ impl AppSettings {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Move a community to the front of the recently opened list
|
||||
pub fn record_recent_community(&mut self, id: String, cx: &mut Context<Self>) {
|
||||
self.inner.update(cx, |this, cx| {
|
||||
this.recent_communities.retain(|existing| existing != &id);
|
||||
this.recent_communities.insert(0, id);
|
||||
this.recent_communities.truncate(RECENT_COMMUNITIES_CAP);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,6 +318,63 @@ pub async fn download_and_decrypt_to_file(
|
||||
Err(anyhow!("File download not supported on web"))
|
||||
}
|
||||
|
||||
/// The cache file a decrypted blob for `plaintext_sha256` is written to.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn blob_cache_path(plaintext_sha256: &str) -> PathBuf {
|
||||
std::env::temp_dir()
|
||||
.join("coop-blobs")
|
||||
.join(plaintext_sha256)
|
||||
}
|
||||
|
||||
/// Download an encrypted blob whose pointer carries the *plaintext* hash
|
||||
/// and write the decrypted bytes to a content-addressed cache file,
|
||||
/// so later renders skip the network.
|
||||
///
|
||||
/// The cache file carries no extension: `img` sniffs the format from the bytes.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn download_and_decrypt_to_cache(
|
||||
url: &Url,
|
||||
key: &str,
|
||||
nonce: &str,
|
||||
plaintext_sha256: &str,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<PathBuf, Error> {
|
||||
let path = blob_cache_path(plaintext_sha256);
|
||||
|
||||
if smol::fs::metadata(&path).await.is_ok() {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
let data = download_and_decrypt(url, key, nonce, None, cx).await?;
|
||||
|
||||
if !sha256_hex(&data).eq_ignore_ascii_case(plaintext_sha256) {
|
||||
bail!("Blob hash mismatch");
|
||||
}
|
||||
|
||||
let Some(parent) = path.parent() else {
|
||||
bail!("Invalid blob cache path");
|
||||
};
|
||||
smol::fs::create_dir_all(parent).await?;
|
||||
|
||||
// Write under a temporary name first, so an interrupted download is never reused
|
||||
let partial = path.with_extension("download");
|
||||
smol::fs::write(&partial, data).await?;
|
||||
smol::fs::rename(&partial, &path).await?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn download_and_decrypt_to_cache(
|
||||
_url: &Url,
|
||||
_key: &str,
|
||||
_nonce: &str,
|
||||
_plaintext_sha256: &str,
|
||||
_cx: &AsyncApp,
|
||||
) -> Result<PathBuf, Error> {
|
||||
Err(anyhow!("Blob download not supported on web"))
|
||||
}
|
||||
|
||||
fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
|
||||
tags.iter()
|
||||
.find(|tag| tag.kind() == name)
|
||||
|
||||
+46
-17
@@ -4,7 +4,7 @@ use anyhow::{Error, anyhow};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use browser_signer_proxy::prelude::*;
|
||||
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 instant::Duration;
|
||||
use nostr_connect::prelude::*;
|
||||
@@ -29,7 +29,7 @@ pub use nip4e::*;
|
||||
pub use nip05::*;
|
||||
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
|
||||
// This only errors if the default provider has already
|
||||
// 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"))]
|
||||
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>);
|
||||
@@ -87,6 +87,9 @@ pub struct NostrRegistry {
|
||||
/// Current user's public key
|
||||
current_user: Option<PublicKey>,
|
||||
|
||||
/// Whether the initial credential check has concluded
|
||||
ready: bool,
|
||||
|
||||
/// Tasks for asynchronous operations
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
}
|
||||
@@ -105,7 +108,8 @@ impl NostrRegistry {
|
||||
}
|
||||
|
||||
/// 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 authenticator = SignerAuthenticator::new(signer.clone());
|
||||
|
||||
@@ -132,25 +136,31 @@ impl NostrRegistry {
|
||||
})
|
||||
.build();
|
||||
|
||||
// Connect to bootstrap relays after the window is ready
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.connect_bootstrap_relays(cx);
|
||||
// Connect to bootstrap relays once the registry has been returned to the app
|
||||
cx.defer(move |cx| {
|
||||
entity
|
||||
.update(cx, |this, cx| {
|
||||
this.connect_bootstrap_relays(cx);
|
||||
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
cx.emit(StateEvent::NoSigner);
|
||||
} else if let Some(secret) = cli_key {
|
||||
// Use CLI-provided key -- same path as get_user_credential
|
||||
let keys = Keys::new(secret);
|
||||
this.set_signer(keys, cx);
|
||||
} else {
|
||||
this.get_user_credential(cx);
|
||||
}
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
this.mark_ready(cx);
|
||||
cx.emit(StateEvent::NoSigner);
|
||||
} else if let Some(secret) = cli_key {
|
||||
// Use CLI-provided key -- same path as get_user_credential
|
||||
let keys = Keys::new(secret);
|
||||
this.set_signer(keys, cx);
|
||||
} else {
|
||||
this.get_user_credential(cx);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
});
|
||||
|
||||
Self {
|
||||
client,
|
||||
signer,
|
||||
current_user: None,
|
||||
ready: false,
|
||||
tasks: vec![],
|
||||
}
|
||||
}
|
||||
@@ -170,6 +180,20 @@ impl NostrRegistry {
|
||||
self.current_user
|
||||
}
|
||||
|
||||
/// Whether the initial credential check has concluded
|
||||
pub fn ready(&self) -> bool {
|
||||
self.ready
|
||||
}
|
||||
|
||||
fn mark_ready(&mut self, cx: &mut Context<Self>) {
|
||||
if self.ready {
|
||||
return;
|
||||
}
|
||||
|
||||
self.ready = true;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Update the signer
|
||||
pub fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
|
||||
where
|
||||
@@ -184,6 +208,7 @@ impl NostrRegistry {
|
||||
this.update(cx, |this, cx| {
|
||||
this.signer.swap_inner(new_signer);
|
||||
this.current_user = Some(public_key);
|
||||
this.mark_ready(cx);
|
||||
cx.emit(StateEvent::SignerChanged);
|
||||
cx.notify();
|
||||
})?;
|
||||
@@ -270,12 +295,16 @@ impl NostrRegistry {
|
||||
} else if content == "proxy" {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
this.update(cx, |this, cx| {
|
||||
this.mark_ready(cx);
|
||||
this.connect_proxy(cx);
|
||||
})?;
|
||||
} else {
|
||||
this.update(cx, |this, cx| this.mark_ready(cx))?;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
this.update(cx, |_, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.mark_ready(cx);
|
||||
cx.emit(StateEvent::NoSigner);
|
||||
})?;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ pub const CLIENT_SIDE_DECORATION_BORDER: Pixels = px(1.0);
|
||||
pub const TITLEBAR_HEIGHT: Pixels = px(36.0);
|
||||
|
||||
/// Defines workspace tabbar height
|
||||
pub const TABBAR_HEIGHT: Pixels = px(44.0);
|
||||
pub const TABBAR_HEIGHT: Pixels = px(36.0);
|
||||
|
||||
/// Defines default sidebar width
|
||||
pub const SIDEBAR_WIDTH: Pixels = px(240.);
|
||||
|
||||
+473
-29
@@ -1,12 +1,25 @@
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AbsoluteLength, App, Div, Hsla, ImageSource, Img, InteractiveElement, Interactivity,
|
||||
IntoElement, ObjectFit, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage,
|
||||
Window, div, img, px,
|
||||
AbsoluteLength, AnyElement, App, Bounds, Div, Hsla, ImageSource, InteractiveElement,
|
||||
Interactivity, IntoElement, ObjectFit, ParentElement, PathBuilder, Pixels, Point, RenderOnce,
|
||||
SharedString, StyleRefinement, Styled, StyledImage, Window, canvas, div, img, point, px,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
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`].
|
||||
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
|
||||
///
|
||||
/// ```
|
||||
/// 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)]
|
||||
pub struct Avatar {
|
||||
base: Div,
|
||||
image: Img,
|
||||
picture: Option<ImageSource>,
|
||||
grayscale: bool,
|
||||
seed: Option<SharedString>,
|
||||
style: StyleRefinement,
|
||||
size: Size,
|
||||
border_color: Option<Hsla>,
|
||||
@@ -39,11 +383,25 @@ pub struct Avatar {
|
||||
}
|
||||
|
||||
impl Avatar {
|
||||
/// Creates a new avatar element with the specified image source.
|
||||
pub fn new(src: impl Into<ImageSource>) -> Self {
|
||||
/// Creates an avatar for an entity whose profile picture may be missing.
|
||||
///
|
||||
/// Use [`Avatar::seed`] to choose the generated
|
||||
/// pixel avatar rendered when `picture` is `None`.
|
||||
pub fn new(picture: Option<SharedString>) -> Self {
|
||||
Self::from_picture(picture.map(ImageSource::from))
|
||||
}
|
||||
|
||||
/// Creates an avatar from an already-resolved source.
|
||||
pub fn from_source(picture: impl Into<ImageSource>) -> Self {
|
||||
Self::from_picture(Some(picture.into()))
|
||||
}
|
||||
|
||||
fn from_picture(picture: Option<ImageSource>) -> Self {
|
||||
Avatar {
|
||||
base: div(),
|
||||
image: img(src),
|
||||
picture,
|
||||
grayscale: false,
|
||||
seed: None,
|
||||
style: StyleRefinement::default(),
|
||||
size: Size::Medium,
|
||||
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.
|
||||
///
|
||||
/// # 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 {
|
||||
self.image = self.image.grayscale(grayscale);
|
||||
self.grayscale = grayscale;
|
||||
self
|
||||
}
|
||||
|
||||
@@ -113,8 +480,24 @@ impl RenderOnce for Avatar {
|
||||
} else {
|
||||
px(0.)
|
||||
};
|
||||
let image_size = avatar_size(self.size);
|
||||
let container_size = image_size.to_pixels(window.rem_size()) + border_width * 2.;
|
||||
let image_size = avatar_size(self.size).to_pixels(window.rem_size());
|
||||
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()
|
||||
.flex_shrink_0()
|
||||
@@ -124,18 +507,79 @@ impl RenderOnce for Avatar {
|
||||
.when_some(self.border_color, |this, color| {
|
||||
this.border(border_width).border_color(color)
|
||||
})
|
||||
.child(
|
||||
self.image
|
||||
.size(image_size)
|
||||
.rounded_full()
|
||||
.object_fit(ObjectFit::Cover)
|
||||
.bg(cx.theme().ghost_element_background)
|
||||
.with_fallback(move || {
|
||||
img("brand/avatar.png")
|
||||
.size(image_size)
|
||||
.rounded_full()
|
||||
.into_any_element()
|
||||
}),
|
||||
)
|
||||
.child(content)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pixel_patterns_are_symmetric_and_stable() {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,26 +116,20 @@ pub trait ButtonVariants: Sized {
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub struct Button {
|
||||
base: BaseButton,
|
||||
|
||||
icon: Option<Icon>,
|
||||
label: Option<SharedString>,
|
||||
tooltip: Option<SharedString>,
|
||||
children: Vec<AnyElement>,
|
||||
|
||||
variant: ButtonVariant,
|
||||
size: Size,
|
||||
|
||||
disabled: bool,
|
||||
loading: bool,
|
||||
|
||||
rounded: bool,
|
||||
compact: bool,
|
||||
caret: bool,
|
||||
indicator: bool,
|
||||
|
||||
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||
on_hover: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
|
||||
|
||||
tab_index: isize,
|
||||
tab_stop: bool,
|
||||
|
||||
|
||||
+176
-59
@@ -24,6 +24,7 @@ use crate::menu::DropdownMenu as _;
|
||||
use crate::resizable::{resize_handle, resize_handle_appearance};
|
||||
use crate::tab::Tab;
|
||||
use crate::tab::tab_bar::TabBar;
|
||||
use crate::title_bar::{TRAFFIC_LIGHT_PADDING, title_bar_drag_handlers, window_controls};
|
||||
use crate::{IconName, Selectable, Sizable, StyledExt, h_flex, v_flex};
|
||||
|
||||
mod panel;
|
||||
@@ -31,12 +32,34 @@ pub use panel::*;
|
||||
|
||||
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(
|
||||
id: impl Into<SharedString>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<DockArea> {
|
||||
let shared = Rc::new(SkinShared::default());
|
||||
) -> (Entity<DockArea>, Rc<TitleBarChrome>) {
|
||||
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| {
|
||||
DockArea::new(id, None, window, cx).with_renderer(Rc::new(DockSkin {
|
||||
shared: shared.clone(),
|
||||
@@ -44,7 +67,7 @@ pub fn dock_area(
|
||||
});
|
||||
|
||||
*shared.area.borrow_mut() = Some(area.downgrade());
|
||||
area
|
||||
(area, chrome)
|
||||
}
|
||||
|
||||
pub fn add_panel(
|
||||
@@ -167,6 +190,7 @@ fn right_top_group(node: &PaneNode) -> Option<NodeId> {
|
||||
struct SkinShared {
|
||||
area: RefCell<Option<WeakEntity<DockArea>>>,
|
||||
resizing: Cell<Option<DockPlacement>>,
|
||||
chrome: Rc<TitleBarChrome>,
|
||||
}
|
||||
|
||||
impl SkinShared {
|
||||
@@ -394,6 +418,53 @@ 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())
|
||||
}
|
||||
|
||||
/// Whether this group is the left dock's root with a single panel.
|
||||
///
|
||||
/// Such a group draws no tab bar, so its panel owns the window's top-left
|
||||
/// corner — including the space the macOS traffic lights overlay.
|
||||
fn is_plain_left_group(&self, group: &TabGroupContext, cx: &App) -> bool {
|
||||
let Some(area) = self.shared.area() else {
|
||||
return false;
|
||||
};
|
||||
let area = area.read(cx);
|
||||
|
||||
area.layout(DockPlacement::Left)
|
||||
.map(|tree| tree.root().id())
|
||||
== Some(group.node())
|
||||
&& group.panels().len() == 1
|
||||
}
|
||||
|
||||
/// Whether this group is the topmost-left group on screen, which sits under
|
||||
/// the native macOS traffic lights. The left dock's group is leftmost while
|
||||
/// it is open and holds a panel; the center's is leftmost otherwise.
|
||||
fn is_leftmost_top_group(&self, group: &TabGroupContext, cx: &App) -> bool {
|
||||
let Some(area) = self.shared.area() else {
|
||||
return false;
|
||||
};
|
||||
let area = area.read(cx);
|
||||
|
||||
let left_open =
|
||||
area.is_dock_open(DockPlacement::Left) && !area.is_empty(DockPlacement::Left, cx);
|
||||
let tree = if left_open {
|
||||
area.layout(DockPlacement::Left)
|
||||
} else {
|
||||
area.layout(DockPlacement::Center)
|
||||
};
|
||||
|
||||
tree.and_then(|tree| left_top_group(tree.root())) == Some(group.node())
|
||||
}
|
||||
|
||||
fn render_toolbar(
|
||||
&self,
|
||||
group: &TabGroupContext,
|
||||
@@ -473,16 +544,19 @@ impl TabGroupSkin {
|
||||
let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
|
||||
let has_leading = left_button.is_some() || bottom_button.is_some();
|
||||
let drag = tab_drag(group, ix, cx);
|
||||
let is_title_bar = self.is_title_bar_group(group, cx);
|
||||
let needs_traffic_light_padding =
|
||||
cfg!(target_os = "macos") && self.is_leftmost_top_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()
|
||||
.items_center()
|
||||
.line_height(rems(1.0))
|
||||
.h(TABBAR_HEIGHT)
|
||||
.py_2()
|
||||
.pl_3()
|
||||
.pr_2()
|
||||
.rounded_t(cx.theme().radius_lg)
|
||||
.bg(cx.theme().panel_background)
|
||||
.when(left_button.is_some(), |this| this.pl_2())
|
||||
.when(right_button.is_some(), |this| this.pr_2())
|
||||
@@ -496,12 +570,15 @@ impl TabGroupSkin {
|
||||
.children(bottom_button),
|
||||
)
|
||||
})
|
||||
.when(needs_traffic_light_padding, |this| {
|
||||
this.pl(px(TRAFFIC_LIGHT_PADDING))
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.id("tab")
|
||||
.flex_1()
|
||||
.flex_initial()
|
||||
.min_w_0()
|
||||
.px_2()
|
||||
.min_w_16()
|
||||
.overflow_hidden()
|
||||
.whitespace_nowrap()
|
||||
.child(
|
||||
@@ -524,6 +601,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(
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
@@ -532,7 +617,18 @@ impl TabGroupSkin {
|
||||
.child(self.render_toolbar(group, window, cx))
|
||||
.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(
|
||||
@@ -556,13 +652,41 @@ impl TabGroupSkin {
|
||||
.iter()
|
||||
.position(|panel| panel.panel_id(cx) == displayed)
|
||||
});
|
||||
let is_title_bar = self.is_title_bar_group(group, cx);
|
||||
let needs_traffic_light_padding =
|
||||
cfg!(target_os = "macos") && self.is_leftmost_top_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)
|
||||
.h(TABBAR_HEIGHT)
|
||||
.bg(cx.theme().panel_background)
|
||||
.rounded_t(cx.theme().radius_lg)
|
||||
.when(has_leading, |this| {
|
||||
.when(needs_traffic_light_padding, |this| {
|
||||
this.pl(px(TRAFFIC_LIGHT_PADDING))
|
||||
})
|
||||
.when(is_title_bar || has_leading, |this| {
|
||||
this.prefix(
|
||||
h_flex()
|
||||
.items_center()
|
||||
@@ -639,26 +763,7 @@ impl TabGroupSkin {
|
||||
})
|
||||
})
|
||||
}))
|
||||
.last_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);
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
.last_empty_space(empty_space)
|
||||
.when(!collapsed, |this| {
|
||||
this.suffix(
|
||||
h_flex()
|
||||
@@ -669,10 +774,22 @@ impl TabGroupSkin {
|
||||
.px_0p5()
|
||||
.gap_1()
|
||||
.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(
|
||||
@@ -738,28 +855,23 @@ impl TabGroupSkin {
|
||||
}
|
||||
|
||||
impl TabGroupRenderer for TabGroupSkin {
|
||||
fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
|
||||
div()
|
||||
.id("tab-panel")
|
||||
.p_1()
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.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);
|
||||
}
|
||||
})
|
||||
fn frame(&self, group: &TabGroupContext, _: &mut Window, _cx: &mut App) -> Stateful<Div> {
|
||||
div().id("tab-panel").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);
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn render_tab_bar(
|
||||
@@ -768,6 +880,12 @@ impl TabGroupRenderer for TabGroupSkin {
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> AnyElement {
|
||||
// The left dock's only panel draws bare, so its content can own the
|
||||
// window's top-left corner instead of a tab bar doing so.
|
||||
if self.is_plain_left_group(group, cx) {
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
|
||||
let visible: Vec<usize> = group
|
||||
.panels()
|
||||
.iter()
|
||||
@@ -811,7 +929,6 @@ impl TabGroupRenderer for TabGroupSkin {
|
||||
.child(
|
||||
div()
|
||||
.size_full()
|
||||
.rounded_b(cx.theme().radius_lg)
|
||||
.bg(cx.theme().panel_background)
|
||||
.overflow_hidden()
|
||||
.child(panel.cached(StyleRefinement::default().v_flex().size_full())),
|
||||
|
||||
@@ -33,12 +33,14 @@ pub enum IconName {
|
||||
Close,
|
||||
CloseCircle,
|
||||
CloseCircleFill,
|
||||
Compass,
|
||||
Copy,
|
||||
Device,
|
||||
Door,
|
||||
Ellipsis,
|
||||
Emoji,
|
||||
Eye,
|
||||
Folder,
|
||||
Input,
|
||||
Info,
|
||||
Invite,
|
||||
@@ -47,6 +49,7 @@ pub enum IconName {
|
||||
Link,
|
||||
Loader,
|
||||
Lock,
|
||||
Message,
|
||||
Moon,
|
||||
Plus,
|
||||
PlusCircle,
|
||||
@@ -63,6 +66,7 @@ pub enum IconName {
|
||||
Ship,
|
||||
Shield,
|
||||
Group,
|
||||
History,
|
||||
UserKey,
|
||||
Upload,
|
||||
Usb,
|
||||
@@ -106,12 +110,14 @@ impl IconNamed for IconName {
|
||||
Self::Close => "icons/close.svg",
|
||||
Self::CloseCircle => "icons/close-circle.svg",
|
||||
Self::CloseCircleFill => "icons/close-circle-fill.svg",
|
||||
Self::Compass => "icons/compass.svg",
|
||||
Self::Copy => "icons/copy.svg",
|
||||
Self::Device => "icons/device.svg",
|
||||
Self::Door => "icons/door.svg",
|
||||
Self::Ellipsis => "icons/ellipsis.svg",
|
||||
Self::Emoji => "icons/emoji.svg",
|
||||
Self::Eye => "icons/eye.svg",
|
||||
Self::Folder => "icons/folder.svg",
|
||||
Self::Input => "icons/input.svg",
|
||||
Self::Info => "icons/info.svg",
|
||||
Self::Invite => "icons/invite.svg",
|
||||
@@ -120,6 +126,7 @@ impl IconNamed for IconName {
|
||||
Self::Link => "icons/link.svg",
|
||||
Self::Loader => "icons/loader.svg",
|
||||
Self::Lock => "icons/lock.svg",
|
||||
Self::Message => "icons/message.svg",
|
||||
Self::Moon => "icons/moon.svg",
|
||||
Self::Plus => "icons/plus.svg",
|
||||
Self::PlusCircle => "icons/plus-circle.svg",
|
||||
@@ -139,6 +146,7 @@ impl IconNamed for IconName {
|
||||
Self::Upload => "icons/upload.svg",
|
||||
Self::Usb => "icons/usb.svg",
|
||||
Self::Group => "icons/group.svg",
|
||||
Self::History => "icons/history.svg",
|
||||
Self::PanelLeft => "icons/panel-left.svg",
|
||||
Self::PanelLeftOpen => "icons/panel-left-open.svg",
|
||||
Self::PanelRight => "icons/panel-right.svg",
|
||||
|
||||
@@ -18,6 +18,7 @@ pub mod indicator;
|
||||
pub mod input;
|
||||
pub mod menu;
|
||||
pub mod modal;
|
||||
pub mod nav_item;
|
||||
pub mod notification;
|
||||
pub mod popover;
|
||||
pub mod resizable;
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
Anchor, Context, DismissEvent, ElementId, Entity, Focusable, InteractiveElement, IntoElement,
|
||||
RenderOnce, SharedString, StyleRefinement, Styled, Window,
|
||||
Anchor, AnyElement, Context, DismissEvent, ElementId, Entity, Focusable, InteractiveElement,
|
||||
IntoElement, MouseButton, RenderOnce, SharedString, StyleRefinement, Styled, Window,
|
||||
};
|
||||
|
||||
use crate::Selectable;
|
||||
use crate::avatar::Avatar;
|
||||
use crate::button::Button;
|
||||
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
|
||||
pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement + 'static {
|
||||
@@ -44,8 +47,7 @@ pub struct DropdownMenuPopover<T: Selectable + IntoElement + 'static> {
|
||||
style: StyleRefinement,
|
||||
anchor: Anchor,
|
||||
trigger: T,
|
||||
#[allow(clippy::type_complexity)]
|
||||
builder: Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>,
|
||||
builder: Rc<MenuBuilder>,
|
||||
}
|
||||
|
||||
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)]
|
||||
struct DropdownMenuState {
|
||||
struct MenuState {
|
||||
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>
|
||||
where
|
||||
T: Selectable + IntoElement + 'static,
|
||||
{
|
||||
fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement {
|
||||
let builder = self.builder.clone();
|
||||
let menu_state =
|
||||
window.use_keyed_state(self.id.clone(), cx, |_, _| DropdownMenuState::default());
|
||||
let menu_state = window.use_keyed_state(self.id.clone(), cx, |_, _| MenuState::default());
|
||||
|
||||
Popover::new(SharedString::from(format!("popover:{}", self.id)))
|
||||
.appearance(false)
|
||||
@@ -100,46 +178,21 @@ where
|
||||
.trigger(self.trigger)
|
||||
.trigger_style(self.style)
|
||||
.anchor(self.anchor)
|
||||
.content(move |_, 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);
|
||||
.content(move |_, window, cx| cached_menu(&menu_state, builder.clone(), window, cx))
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for dismiss events from the PopupMenu to close the popover.
|
||||
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();
|
||||
impl RenderOnce for ContextMenu {
|
||||
fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement {
|
||||
let builder = self.builder.clone();
|
||||
let menu_state = window.use_keyed_state(self.id.clone(), cx, |_, _| MenuState::default());
|
||||
|
||||
menu.clone()
|
||||
}
|
||||
};
|
||||
|
||||
menu.clone()
|
||||
})
|
||||
Popover::new(SharedString::from(format!("context-menu:{}", self.id)))
|
||||
.appearance(false)
|
||||
.overlay_closable(false)
|
||||
.anchor(self.anchor)
|
||||
.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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ mod dropdown_menu;
|
||||
mod menu_item;
|
||||
mod popup_menu;
|
||||
|
||||
pub use dropdown_menu::DropdownMenu;
|
||||
pub use dropdown_menu::{ContextMenu, DropdownMenu};
|
||||
pub use popup_menu::{PopupMenu, PopupMenuItem};
|
||||
|
||||
pub(crate) fn init(cx: &mut App) {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
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.
|
||||
#[allow(clippy::type_complexity)]
|
||||
#[derive(IntoElement)]
|
||||
pub struct NavItem {
|
||||
id: ElementId,
|
||||
style: StyleRefinement,
|
||||
icon: AnyElement,
|
||||
label: SharedString,
|
||||
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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,19 @@ impl Popover {
|
||||
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`.
|
||||
///
|
||||
/// This is only used to initialize the open state of the popover.
|
||||
|
||||
@@ -2,9 +2,10 @@ use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder as _;
|
||||
use gpui::{
|
||||
AnyElement, App, ClickEvent, Context, Decorations, Hsla, InteractiveElement, IntoElement,
|
||||
MouseButton, ParentElement, Pixels, Render, RenderOnce, StatefulInteractiveElement as _,
|
||||
StyleRefinement, Styled, TitlebarOptions, Window, WindowControlArea, div, px,
|
||||
AnyElement, App, ClickEvent, Context, Decorations, Div, Hsla, InteractiveElement, IntoElement,
|
||||
MouseButton, ParentElement, Pixels, Render, RenderOnce, Stateful,
|
||||
StatefulInteractiveElement as _, StyleRefinement, Styled, TitlebarOptions, Window,
|
||||
WindowControlArea, div, px,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
use theme::ActiveTheme;
|
||||
@@ -210,10 +211,61 @@ impl RenderOnce for ControlIcon {
|
||||
|
||||
#[derive(IntoElement)]
|
||||
#[allow(clippy::type_complexity)]
|
||||
struct WindowControls {
|
||||
pub(crate) struct WindowControls {
|
||||
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 {
|
||||
fn render(self, window: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
if cfg!(target_os = "macos") || cfg!(target_family = "wasm") {
|
||||
|
||||
@@ -12,6 +12,8 @@ state = { path = "../state" }
|
||||
device = { path = "../device" }
|
||||
chat = { path = "../chat" }
|
||||
chat_ui = { path = "../chat_ui" }
|
||||
community = { path = "../community" }
|
||||
community_ui = { path = "../community_ui" }
|
||||
settings = { path = "../settings" }
|
||||
person = { path = "../person" }
|
||||
auto_update = { path = "../auto_update" }
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use anyhow::{Error, anyhow};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
||||
Subscription, Task, Window, div,
|
||||
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
||||
Subscription, Task, Window, div, px,
|
||||
};
|
||||
use instant::Duration;
|
||||
use nostr_connect::prelude::*;
|
||||
@@ -12,6 +12,19 @@ use ui::button::{Button, ButtonVariants};
|
||||
use ui::input::{Input, InputEvent, InputState};
|
||||
use ui::{Disableable, StyledExt, WindowExtension, divider, v_flex};
|
||||
|
||||
pub fn open(window: &mut Window, cx: &mut App) {
|
||||
let import = cx.new(|cx| ImportIdentity::new(window, cx));
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
this.width(px(450.))
|
||||
.show_close(false)
|
||||
.overlay_closable(false)
|
||||
.keyboard(false)
|
||||
.title("Onboarding")
|
||||
.child(import.clone())
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ImportIdentity {
|
||||
/// Secret key input
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod import;
|
||||
pub mod new_chat;
|
||||
pub mod new_community;
|
||||
pub mod restore;
|
||||
pub mod screening;
|
||||
pub mod settings;
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
use chat::{ChatRegistry, Room, RoomKind};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
|
||||
Subscription, Window, div, px,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::input::{Input, InputEvent, InputState};
|
||||
use ui::{StyledExt, WindowExtension, v_flex};
|
||||
|
||||
pub fn open(window: &mut Window, cx: &mut App) {
|
||||
let view = cx.new(|cx| NewChat::new(window, cx));
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
this.width(px(420.)).title("New chat").child(view.clone())
|
||||
});
|
||||
}
|
||||
|
||||
pub struct NewChat {
|
||||
/// Public key input
|
||||
input: Entity<InputState>,
|
||||
|
||||
/// Error message
|
||||
error: Option<SharedString>,
|
||||
|
||||
/// Input subscription
|
||||
_subscription: Option<Subscription>,
|
||||
}
|
||||
|
||||
impl NewChat {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let input = cx.new(|cx| InputState::new(window, cx).placeholder("npub"));
|
||||
|
||||
let subscription = cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.start_chat(window, cx);
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
input,
|
||||
error: None,
|
||||
_subscription: Some(subscription),
|
||||
}
|
||||
}
|
||||
|
||||
fn start_chat(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let value = self.input.read(cx).value().to_string();
|
||||
|
||||
let Ok(peer) = PublicKey::parse(&value) else {
|
||||
self.set_error("Public key is invalid", cx);
|
||||
return;
|
||||
};
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let Some(current_user) = nostr.read(cx).current_user() else {
|
||||
self.set_error("You are not signed in", cx);
|
||||
return;
|
||||
};
|
||||
|
||||
if peer == current_user {
|
||||
self.set_error("You cannot chat with yourself", cx);
|
||||
return;
|
||||
}
|
||||
|
||||
let room = Room::new(current_user, [peer])
|
||||
.organize(¤t_user)
|
||||
.kind(RoomKind::Ongoing);
|
||||
|
||||
let chat = ChatRegistry::global(cx);
|
||||
chat.update(cx, |chat, cx| {
|
||||
let room = cx.new(|_| room);
|
||||
chat.emit_room(&room, window, cx);
|
||||
});
|
||||
|
||||
window.close_modal(cx);
|
||||
}
|
||||
|
||||
fn set_error(&mut self, message: impl Into<SharedString>, cx: &mut Context<Self>) {
|
||||
self.error = Some(message.into());
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for NewChat {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("Public key of the person you want to chat with")
|
||||
.child(Input::new(&self.input)),
|
||||
)
|
||||
.child(
|
||||
Button::new("start-chat")
|
||||
.label("Start chat")
|
||||
.primary()
|
||||
.font_semibold()
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
this.start_chat(window, cx);
|
||||
})),
|
||||
)
|
||||
.when_some(self.error.clone(), |this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_center()
|
||||
.text_color(cx.theme().text_danger)
|
||||
.child(error),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
use community::{CommunityMetadata, CommunityRegistry};
|
||||
use gpui::{App, AppContext, ParentElement, Window, px};
|
||||
use ui::WindowExtension;
|
||||
use ui::input::{Input, InputState};
|
||||
|
||||
pub fn open(window: &mut Window, cx: &mut App) {
|
||||
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Community name"));
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
let name_input = name_input.clone();
|
||||
|
||||
this.width(px(380.))
|
||||
.confirm()
|
||||
.title("New community")
|
||||
.child(Input::new(&name_input))
|
||||
.on_ok(move |_event, _window, cx| {
|
||||
let name = name_input.read(cx).value().trim().to_owned();
|
||||
|
||||
if name.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let metadata = CommunityMetadata {
|
||||
name,
|
||||
..CommunityMetadata::default()
|
||||
};
|
||||
|
||||
CommunityRegistry::global(cx)
|
||||
.update(cx, |registry, cx| registry.create(metadata, cx));
|
||||
|
||||
true
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -1,545 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::Error;
|
||||
use common::TimestampExt;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement, Render,
|
||||
SharedString, Styled, Subscription, Task, Window, div, px, relative, uniform_list,
|
||||
};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{Person, PersonRegistry, shorten_pubkey};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT};
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::indicator::Indicator;
|
||||
use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||
|
||||
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<Screening> {
|
||||
cx.new(|cx| Screening::new(public_key, window, cx))
|
||||
}
|
||||
|
||||
/// Screening
|
||||
pub struct Screening {
|
||||
/// Public Key of the person being screened.
|
||||
public_key: PublicKey,
|
||||
|
||||
/// Whether the person's address is verified.
|
||||
verified: bool,
|
||||
|
||||
/// Whether the person is followed by current user.
|
||||
followed: bool,
|
||||
|
||||
/// Last time the person was active.
|
||||
last_active: Option<Timestamp>,
|
||||
|
||||
/// All mutual contacts of the person being screened.
|
||||
mutual_contacts: Vec<PublicKey>,
|
||||
|
||||
/// Async tasks
|
||||
tasks: SmallVec<[Task<()>; 3]>,
|
||||
|
||||
/// Subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl Screening {
|
||||
pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(cx.on_release_in(window, move |this, window, cx| {
|
||||
this.tasks.clear();
|
||||
window.close_all_modals(cx);
|
||||
}));
|
||||
|
||||
cx.defer_in(window, |this, _window, cx| {
|
||||
this.check_contact(cx);
|
||||
this.check_wot(cx);
|
||||
this.check_last_activity(cx);
|
||||
this.verify_identifier(cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
public_key,
|
||||
verified: false,
|
||||
followed: false,
|
||||
last_active: None,
|
||||
mutual_contacts: vec![],
|
||||
tasks: smallvec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
fn check_contact(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let public_key = self.public_key;
|
||||
|
||||
let Some(current_user) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let task: Task<Result<bool, Error>> = cx.background_spawn(async move {
|
||||
// Check if user is in contact list
|
||||
let filter = Filter::new()
|
||||
.author(current_user)
|
||||
.kind(Kind::ContactList)
|
||||
.limit(1);
|
||||
|
||||
let followed = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|event| event.tags.public_keys().any(|k| k == public_key))
|
||||
.unwrap_or(false);
|
||||
|
||||
Ok(followed)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let result = task.await.unwrap_or(false);
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.followed = result;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
fn check_wot(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let public_key = self.public_key;
|
||||
|
||||
let Some(current_user) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let task: Task<Result<Vec<PublicKey>, Error>> = cx.background_spawn(async move {
|
||||
// Check mutual contacts
|
||||
let filter = Filter::new().kind(Kind::ContactList).pubkey(public_key);
|
||||
let mut mutual_contacts = vec![];
|
||||
|
||||
if let Ok(events) = client.database().query(filter).await {
|
||||
for event in events.into_iter().filter(|ev| ev.pubkey != current_user) {
|
||||
mutual_contacts.push(event.pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(mutual_contacts)
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(contacts) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.mutual_contacts = contacts;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to fetch mutual contacts: {}", e);
|
||||
}
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
fn check_last_activity(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let public_key = self.public_key;
|
||||
|
||||
let task: Task<Option<Timestamp>> = cx.background_spawn(async move {
|
||||
let filter = Filter::new().author(public_key).limit(1);
|
||||
let mut activity: Option<Timestamp> = None;
|
||||
|
||||
// Construct target for subscription
|
||||
let target: HashMap<&str, Vec<Filter>> = BOOTSTRAP_RELAYS
|
||||
.into_iter()
|
||||
.map(|relay| (relay, vec![filter.clone()]))
|
||||
.collect();
|
||||
|
||||
if let Ok(mut stream) = client
|
||||
.stream_events(target)
|
||||
.timeout(Duration::from_secs(TIMEOUT))
|
||||
.await
|
||||
{
|
||||
while let Some((_url, event)) = stream.next().await {
|
||||
if let Ok(event) = event {
|
||||
activity = Some(event.created_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activity
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let result = task.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.last_active = result;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
fn verify_identifier(&mut self, cx: &mut Context<Self>) {
|
||||
let http_client = cx.http_client();
|
||||
let public_key = self.public_key;
|
||||
|
||||
// Skip if the user doesn't have a NIP-05 identifier
|
||||
let Some(address) = self.address(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let task: Task<Result<bool, Error>> =
|
||||
cx.background_spawn(async move { address.verify(&http_client, &public_key).await });
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let result = task.await.unwrap_or(false);
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.verified = result;
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
fn profile(&self, cx: &Context<Self>) -> Person {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
persons.read(cx).get(&self.public_key, cx)
|
||||
}
|
||||
|
||||
fn address(&self, cx: &Context<Self>) -> Option<Nip05Address> {
|
||||
self.profile(cx)
|
||||
.metadata()
|
||||
.nip05
|
||||
.and_then(|addr| Nip05Address::parse(&addr).ok())
|
||||
}
|
||||
|
||||
fn open_njump(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Ok(bech32) = self.profile(cx).public_key().to_bech32();
|
||||
cx.open_url(&format!("https://njump.me/{bech32}"));
|
||||
}
|
||||
|
||||
fn report(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
let public_key = self.public_key;
|
||||
|
||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||
let tag = Tag::from(Nip56Tag::PublicKey {
|
||||
public_key,
|
||||
report: Report::Impersonation,
|
||||
});
|
||||
|
||||
let event = EventBuilder::new(Kind::Reporting, "")
|
||||
.tag(tag)
|
||||
.finalize_async(&signer)
|
||||
.await?;
|
||||
|
||||
// Send the report to the public relays
|
||||
client.send_event(&event).to(BOOTSTRAP_RELAYS).await?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |_, cx| {
|
||||
if task.await.is_ok() {
|
||||
cx.update(|window, cx| {
|
||||
window.close_modal(cx);
|
||||
window.push_notification("Report submitted successfully", cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let contacts = self.mutual_contacts.clone();
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
let contacts = contacts.clone();
|
||||
let total = contacts.len();
|
||||
|
||||
this.title("Mutual contacts").child(
|
||||
v_flex().gap_1().pb_2().child(
|
||||
uniform_list("contacts", total, move |range, _window, cx| {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let mut items = Vec::with_capacity(total);
|
||||
|
||||
for ix in range {
|
||||
let Some(contact) = contacts.get(ix) else {
|
||||
continue;
|
||||
};
|
||||
let profile = persons.read(cx).get(contact, cx);
|
||||
|
||||
items.push(
|
||||
h_flex()
|
||||
.h_11()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.gap_1p5()
|
||||
.rounded(cx.theme().radius)
|
||||
.text_sm()
|
||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||
.child(Avatar::new(profile.avatar()).small())
|
||||
.child(profile.name()),
|
||||
);
|
||||
}
|
||||
|
||||
items
|
||||
})
|
||||
.h(px(300.)),
|
||||
),
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Screening {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
const CONTACT: &str = "This person is one of your contacts.";
|
||||
const NOT_CONTACT: &str = "This person is not one of your contacts.";
|
||||
const NO_ACTIVITY: &str = "This person hasn't had any activity.";
|
||||
const RELAY_INFO: &str = "Only checked on public relays; may be inaccurate.";
|
||||
const NO_MUTUAL: &str = "You don't have any mutual contacts.";
|
||||
const NIP05_MATCH: &str = "The address matches the user's public key.";
|
||||
const NIP05_NOT_MATCH: &str = "The address does not match the user's public key.";
|
||||
const NO_NIP05: &str = "This person has not set up their friendly address";
|
||||
|
||||
let profile = self.profile(cx);
|
||||
let shorten_pubkey = shorten_pubkey(self.public_key, 8);
|
||||
|
||||
let last_active = self.last_active.map(|_| true);
|
||||
let mutuals = self.mutual_contacts.len();
|
||||
let mutuals_str = format!("You have {} mutual contacts with this person.", mutuals);
|
||||
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.child(Avatar::new(profile.avatar()).large())
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.25))
|
||||
.child(profile.name()),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
h_flex()
|
||||
.p_1()
|
||||
.flex_1()
|
||||
.h_7()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.text_sm()
|
||||
.truncate()
|
||||
.text_ellipsis()
|
||||
.text_center()
|
||||
.line_height(relative(1.))
|
||||
.child(shorten_pubkey),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new("njump")
|
||||
.icon(IconName::Link)
|
||||
.label("njump.me")
|
||||
.secondary()
|
||||
.small()
|
||||
.rounded()
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
this.open_njump(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("report")
|
||||
.tooltip("Report as a scam or impostor")
|
||||
.icon(IconName::Warning)
|
||||
.small()
|
||||
.warning()
|
||||
.rounded()
|
||||
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||
this.report(window, cx);
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(status_badge(Some(self.followed), cx))
|
||||
.child(
|
||||
v_flex().text_sm().child("Contact").child(
|
||||
div()
|
||||
.line_clamp(1)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child({
|
||||
if self.followed {
|
||||
SharedString::from(CONTACT)
|
||||
} else {
|
||||
SharedString::from(NOT_CONTACT)
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(status_badge(last_active, cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.text_sm()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.child("Activity on Public Relays")
|
||||
.child(
|
||||
Button::new("active")
|
||||
.icon(IconName::Info)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.rounded()
|
||||
.tooltip(RELAY_INFO),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.line_clamp(1)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.map(|this| {
|
||||
if let Some(t) = self.last_active {
|
||||
this.child(SharedString::from(format!(
|
||||
"Last active: {}.",
|
||||
t.to_human_time()
|
||||
)))
|
||||
} else {
|
||||
this.child(SharedString::from(NO_ACTIVITY))
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.child(status_badge(Some(self.verified), cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.text_sm()
|
||||
.child({
|
||||
if let Some(addr) = self.address(cx) {
|
||||
SharedString::from(format!("{} validation", addr))
|
||||
} else {
|
||||
SharedString::from(
|
||||
"Friendly Address (NIP-05) validation",
|
||||
)
|
||||
}
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.line_clamp(1)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child({
|
||||
if self.address(cx).is_some() {
|
||||
if self.verified {
|
||||
SharedString::from(NIP05_MATCH)
|
||||
} else {
|
||||
SharedString::from(NIP05_NOT_MATCH)
|
||||
}
|
||||
} else {
|
||||
SharedString::from(NO_NIP05)
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.child(status_badge(Some(mutuals > 0), cx))
|
||||
.child(
|
||||
h_flex()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.line_clamp(1)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child({
|
||||
if mutuals > 0 {
|
||||
SharedString::from(mutuals_str)
|
||||
} else {
|
||||
SharedString::from(NO_MUTUAL)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("mutuals")
|
||||
.icon(IconName::Info)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.rounded()
|
||||
.disabled(mutuals == 0)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.mutual_contacts(window, cx);
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn status_badge(status: Option<bool>, cx: &App) -> Div {
|
||||
h_flex()
|
||||
.size_6()
|
||||
.justify_center()
|
||||
.flex_shrink_0()
|
||||
.map(|this| {
|
||||
if let Some(status) = status {
|
||||
this.child(Icon::new(IconName::CheckCircle).small().text_color({
|
||||
if status {
|
||||
cx.theme().icon_accent
|
||||
} else {
|
||||
cx.theme().icon_muted
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
this.child(Indicator::new().small())
|
||||
}
|
||||
})
|
||||
}
|
||||
+88
-148
@@ -1,3 +1,4 @@
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use ::settings::AppSettings;
|
||||
@@ -5,11 +6,12 @@ use anyhow::Error;
|
||||
use auto_update::AutoUpdater;
|
||||
use chat::{ChatEvent, ChatRegistry};
|
||||
use common::download_dir;
|
||||
use community::{CommunityEvent, CommunityRegistry};
|
||||
use device::{DeviceEvent, DeviceRegistry};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
Action, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
||||
Render, SharedString, Styled, Subscription, Task, Window, div, px,
|
||||
Action, AnyElement, App, AppContext, Context, Entity, InteractiveElement, IntoElement,
|
||||
ParentElement, Render, SharedString, Styled, Subscription, Task, Window, div, px,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{PersonRegistry, shorten_pubkey};
|
||||
@@ -17,17 +19,18 @@ use serde::Deserialize;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{NostrRegistry, StateEvent};
|
||||
use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry};
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock::{self, ClosePanel, DockArea, DockLayout, DockPlacement, Panel, PanelHandle};
|
||||
use ui::menu::{DropdownMenu, PopupMenuItem};
|
||||
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::restore::RestoreEncryption;
|
||||
use crate::dialogs::settings;
|
||||
use crate::panels::{backup, contact_list, greeter, messaging_relays, profile, relay_list};
|
||||
use crate::dialogs::{new_chat, new_community, settings};
|
||||
use crate::panels::{
|
||||
backup, browse, contact_list, greeter, inbox, messaging_relays, profile, relay_list, requests,
|
||||
search,
|
||||
};
|
||||
use crate::sidebar::Sidebar;
|
||||
|
||||
mod dialogs;
|
||||
@@ -57,28 +60,32 @@ enum Command {
|
||||
ShowSettings,
|
||||
ShowBackup,
|
||||
ShowContactList,
|
||||
ShowInbox,
|
||||
ShowRequests,
|
||||
ShowBrowse,
|
||||
ShowSearch,
|
||||
NewChat,
|
||||
NewCommunity,
|
||||
}
|
||||
|
||||
pub struct Workspace {
|
||||
sidebar: Entity<Sidebar>,
|
||||
/// App's Dock Area
|
||||
dock: Entity<DockArea>,
|
||||
|
||||
title_bar_chrome: Rc<dock::TitleBarChrome>,
|
||||
/// Async tasks
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 6]>,
|
||||
_subscriptions: SmallVec<[Subscription; 7]>,
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let communities = CommunityRegistry::global(cx);
|
||||
let device = DeviceRegistry::global(cx);
|
||||
let nostr = NostrRegistry::global(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![];
|
||||
|
||||
@@ -91,16 +98,10 @@ impl Workspace {
|
||||
|
||||
subscriptions.push(
|
||||
// Subscribe to the nostr events
|
||||
cx.subscribe_in(&nostr, window, move |this, _state, event, window, cx| {
|
||||
match event {
|
||||
StateEvent::SignerChanged => {
|
||||
window.close_all_modals(cx);
|
||||
}
|
||||
StateEvent::NoSigner => {
|
||||
this.import_identity(window, cx);
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
cx.subscribe_in(&nostr, window, move |_this, _state, event, window, cx| {
|
||||
if let StateEvent::SignerChanged = event {
|
||||
window.close_all_modals(cx);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -213,18 +214,47 @@ impl Workspace {
|
||||
}),
|
||||
);
|
||||
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
subscriptions.push(
|
||||
// Observe all events emitted by the community registry
|
||||
cx.subscribe_in(
|
||||
&communities,
|
||||
window,
|
||||
move |this, communities, event, window, cx| {
|
||||
if let CommunityEvent::Open(id) = event
|
||||
&& let Some(community) = communities.read(cx).community(id)
|
||||
{
|
||||
this.add_panel_to_dock(
|
||||
community_ui::init(community, window, cx),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
cx.defer_in(window, move |this, window, cx| {
|
||||
let sidebar = PanelHandle::new(sidebar);
|
||||
|
||||
this.dock.update(cx, |area, cx| {
|
||||
let left = DockLayout::tabs().panel_view(Arc::new(sidebar), cx);
|
||||
area.set_dock(DockPlacement::Left, left, window, cx);
|
||||
area.set_dock_size(DockPlacement::Left, SIDEBAR_WIDTH, window, cx);
|
||||
});
|
||||
|
||||
let greeter = PanelHandle::new(greeter::init(window, cx));
|
||||
let center = DockLayout::v_split()
|
||||
.child(DockLayout::tabs().panel_view(Arc::new(greeter), cx), None);
|
||||
|
||||
this.dock
|
||||
.update(cx, |area, cx| area.set_center(center, window, cx));
|
||||
this.dock.update(cx, |area, cx| {
|
||||
area.set_center(center, window, cx);
|
||||
});
|
||||
});
|
||||
|
||||
Self {
|
||||
sidebar,
|
||||
dock,
|
||||
title_bar_chrome,
|
||||
tasks: vec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
@@ -294,6 +324,29 @@ impl Workspace {
|
||||
cx,
|
||||
);
|
||||
}
|
||||
Command::ShowInbox => {
|
||||
self.add_panel_to_dock(inbox::init(window, cx), DockPlacement::Center, window, cx);
|
||||
}
|
||||
Command::ShowRequests => {
|
||||
self.add_panel_to_dock(
|
||||
requests::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::NewChat => {
|
||||
new_chat::open(window, cx);
|
||||
}
|
||||
Command::NewCommunity => {
|
||||
new_community::open(window, cx);
|
||||
}
|
||||
Command::ShowBackup => {
|
||||
self.add_panel_to_dock(backup::init(window, cx), DockPlacement::Left, window, cx);
|
||||
}
|
||||
@@ -425,19 +478,6 @@ impl Workspace {
|
||||
});
|
||||
}
|
||||
|
||||
fn import_identity(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let import = cx.new(|cx| ImportIdentity::new(window, cx));
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
this.width(px(450.))
|
||||
.show_close(false)
|
||||
.overlay_closable(false)
|
||||
.keyboard(false)
|
||||
.title("Onboarding")
|
||||
.child(import.clone())
|
||||
});
|
||||
}
|
||||
|
||||
fn theme_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
window.open_modal(cx, move |this, _window, cx| {
|
||||
let registry = ThemeRegistry::global(cx);
|
||||
@@ -518,95 +558,14 @@ impl Workspace {
|
||||
});
|
||||
}
|
||||
|
||||
fn titlebar_left(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let current_user = nostr.read(cx).current_user();
|
||||
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.gap_2()
|
||||
.when_none(¤t_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 {
|
||||
fn titlebar_right(_window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
let auto_updater = AutoUpdater::try_global(cx);
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let nip4e_enabled = AppSettings::get_nip4e(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
let Some(public_key) = nostr.read(cx).current_user() else {
|
||||
return div();
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
let persons = PersonRegistry::global(cx);
|
||||
@@ -635,11 +594,11 @@ impl Workspace {
|
||||
.tooltip("Quit and relaunch into the installed update")
|
||||
.small()
|
||||
.ghost()
|
||||
.on_click(cx.listener(|_this, _event, _window, cx| {
|
||||
.on_click(|_event, _window, cx| {
|
||||
if let Some(auto_updater) = AutoUpdater::try_global(cx) {
|
||||
auto_updater.update(cx, |this, cx| this.restart(cx));
|
||||
}
|
||||
})),
|
||||
}),
|
||||
)
|
||||
})
|
||||
.when(nip4e_enabled, |this| {
|
||||
@@ -764,6 +723,7 @@ impl Workspace {
|
||||
)
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,34 +732,14 @@ impl Render for Workspace {
|
||||
let modal_layer = Root::render_modal_layer(window, cx);
|
||||
let notification_layer = Root::render_notification_layer(window, cx);
|
||||
|
||||
self.title_bar_chrome.set_trailing(Self::titlebar_right);
|
||||
|
||||
div()
|
||||
.id("workspace")
|
||||
.on_action(cx.listener(Self::on_command))
|
||||
.relative()
|
||||
.size_full()
|
||||
.child(
|
||||
v_flex()
|
||||
.size_full()
|
||||
// Title Bar
|
||||
.child(
|
||||
TitleBar::new()
|
||||
.child(self.titlebar_left(cx))
|
||||
.child(self.titlebar_right(cx)),
|
||||
)
|
||||
// Main
|
||||
.child(
|
||||
h_flex()
|
||||
.size_full()
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.h_full()
|
||||
.w(SIDEBAR_WIDTH)
|
||||
.child(self.sidebar.clone()),
|
||||
)
|
||||
.child(self.dock.clone()),
|
||||
),
|
||||
)
|
||||
.child(self.dock.clone())
|
||||
// Notifications
|
||||
.children(notification_layer)
|
||||
// Modals
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -239,7 +239,11 @@ impl ContactListPanel {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(Avatar::new(profile.avatar()).small())
|
||||
.child(
|
||||
Avatar::new(profile.avatar())
|
||||
.seed(profile.avatar_seed())
|
||||
.small(),
|
||||
)
|
||||
.child(profile.name()),
|
||||
)
|
||||
.child(
|
||||
|
||||
@@ -157,8 +157,11 @@ impl Render for GreeterPanel {
|
||||
.label("Change theme")
|
||||
.ghost()
|
||||
.small()
|
||||
.on_click(cx.listener(move |_, _, _, cx| {
|
||||
cx.dispatch_action(&Command::ToggleTheme);
|
||||
.on_click(cx.listener(move |_, _, window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(Command::ToggleTheme),
|
||||
cx,
|
||||
);
|
||||
})),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
pub mod backup;
|
||||
pub mod browse;
|
||||
pub mod contact_list;
|
||||
pub mod greeter;
|
||||
pub mod inbox;
|
||||
pub mod messaging_relays;
|
||||
pub mod profile;
|
||||
pub mod relay_list;
|
||||
pub mod requests;
|
||||
pub mod search;
|
||||
|
||||
@@ -309,12 +309,7 @@ impl Render for ProfilePanel {
|
||||
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let avatar_input = self.avatar_input.read(cx).value();
|
||||
|
||||
// Get the avatar
|
||||
let avatar = if avatar_input.is_empty() {
|
||||
"brand/avatar.png"
|
||||
} else {
|
||||
avatar_input.as_str()
|
||||
};
|
||||
let picture = (!avatar_input.is_empty()).then_some(avatar_input);
|
||||
|
||||
// Get the public key as short string
|
||||
let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
|
||||
@@ -331,7 +326,7 @@ impl Render for ProfilePanel {
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_4()
|
||||
.child(Avatar::new(avatar).large())
|
||||
.child(Avatar::new(picture).seed(self.public_key.to_hex()).large())
|
||||
.child(
|
||||
Button::new("upload")
|
||||
.icon(IconName::PlusCircle)
|
||||
|
||||
@@ -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<RequestsPanel> {
|
||||
cx.new(|cx| RequestsPanel::new(window, cx))
|
||||
}
|
||||
|
||||
pub struct RequestsPanel {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl RequestsPanel {
|
||||
fn new(_window: &mut Window, cx: &mut App) -> Self {
|
||||
Self {
|
||||
name: "Requests".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for RequestsPanel {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn title(&self, cx: &App) -> AnyElement {
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.child(
|
||||
Icon::new(IconName::Invite)
|
||||
.small()
|
||||
.text_color(cx.theme().icon_muted),
|
||||
)
|
||||
.child(self.name.clone())
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for RequestsPanel {}
|
||||
|
||||
impl Focusable for RequestsPanel {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for RequestsPanel {
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
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, ElementId, 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::{TreeRow, TreeRowKind};
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
TreeRow::new(
|
||||
ElementId::NamedInteger("search-result".into(), (range.start + ix) as u64),
|
||||
TreeRowKind::Room,
|
||||
profile.name(),
|
||||
)
|
||||
.avatar(profile.avatar_seed())
|
||||
.picture(profile.avatar())
|
||||
.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);
|
||||
});
|
||||
|
||||
TreeRow::new(
|
||||
ElementId::NamedInteger("contact".into(), (range.start + ix) as u64),
|
||||
TreeRowKind::Room,
|
||||
profile.name().trim(),
|
||||
)
|
||||
.avatar(profile.avatar_seed())
|
||||
.picture(profile.avatar())
|
||||
.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);
|
||||
})),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use chat::RoomKind;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce, SharedString,
|
||||
StatefulInteractiveElement, Styled, Window, div,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use settings::AppSettings;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::dock::ClosePanel;
|
||||
use ui::modal::ModalButtonProps;
|
||||
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex};
|
||||
|
||||
use crate::dialogs::screening;
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct RoomEntry {
|
||||
ix: usize,
|
||||
public_key: Option<PublicKey>,
|
||||
name: Option<SharedString>,
|
||||
avatar: Option<SharedString>,
|
||||
created_at: Option<SharedString>,
|
||||
kind: Option<RoomKind>,
|
||||
selected: bool,
|
||||
#[allow(clippy::type_complexity)]
|
||||
handler: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||
}
|
||||
|
||||
impl RoomEntry {
|
||||
pub fn new(ix: usize) -> Self {
|
||||
Self {
|
||||
ix,
|
||||
public_key: None,
|
||||
name: None,
|
||||
avatar: None,
|
||||
created_at: None,
|
||||
kind: None,
|
||||
handler: None,
|
||||
selected: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn public_key(mut self, public_key: PublicKey) -> Self {
|
||||
self.public_key = Some(public_key);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn name(mut self, name: impl Into<SharedString>) -> Self {
|
||||
self.name = Some(name.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn avatar(mut self, avatar: impl Into<SharedString>) -> Self {
|
||||
self.avatar = Some(avatar.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn created_at(mut self, created_at: impl Into<SharedString>) -> Self {
|
||||
self.created_at = Some(created_at.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn kind(mut self, kind: RoomKind) -> Self {
|
||||
self.kind = Some(kind);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.handler = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Selectable for RoomEntry {
|
||||
fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
fn is_selected(&self) -> bool {
|
||||
self.selected
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for RoomEntry {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
let screening = AppSettings::get_screening(cx);
|
||||
|
||||
let public_key = self.public_key;
|
||||
let is_selected = self.is_selected();
|
||||
|
||||
h_flex()
|
||||
.id(self.ix)
|
||||
.h_9()
|
||||
.w_full()
|
||||
.px_1p5()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.rounded(cx.theme().radius)
|
||||
.when(!hide_avatar, |this| {
|
||||
this.when_some(self.avatar, |this, avatar| {
|
||||
this.child(Avatar::new(avatar).small().flex_shrink_0())
|
||||
})
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.when_some(self.name, |this, name| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.flex_1()
|
||||
.justify_between()
|
||||
.line_clamp(1)
|
||||
.text_ellipsis()
|
||||
.truncate()
|
||||
.font_medium()
|
||||
.child(name)
|
||||
.when(is_selected, |this| {
|
||||
this.child(
|
||||
Icon::new(IconName::CheckCircle)
|
||||
.small()
|
||||
.text_color(cx.theme().icon_accent),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.flex_shrink_0()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.when_some(self.created_at, |this, created_at| this.child(created_at)),
|
||||
),
|
||||
)
|
||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||
.when_some(self.handler, |this, handler| {
|
||||
this.on_click(move |event, window, cx| {
|
||||
handler(event, window, cx);
|
||||
|
||||
if let Some(public_key) = public_key
|
||||
&& self.kind != Some(RoomKind::Ongoing)
|
||||
&& screening
|
||||
{
|
||||
let screening = screening::init(public_key, window, cx);
|
||||
|
||||
window.open_modal(cx, move |this, _window, _cx| {
|
||||
this.confirm()
|
||||
.child(screening.clone())
|
||||
.button_props(
|
||||
ModalButtonProps::default()
|
||||
.cancel_text("Ignore")
|
||||
.ok_text("Response"),
|
||||
)
|
||||
.on_cancel(move |_event, window, cx| {
|
||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
||||
true
|
||||
})
|
||||
});
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
+494
-624
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
use gpui::{App, InteractiveElement, IntoElement, ParentElement, Styled, Window, div, svg};
|
||||
use theme::{ActiveTheme, TABBAR_HEIGHT};
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::{StyledExt, h_flex, title_bar_drag_handlers, v_flex};
|
||||
|
||||
use crate::dialogs::import;
|
||||
|
||||
const TITLE: &str = "Welcome to Coop!";
|
||||
const DESCRIPTION: &str = "Chat Freely, Stay Private on Nostr.";
|
||||
|
||||
pub(super) fn render(window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.relative()
|
||||
.bg(cx.theme().surface_background)
|
||||
.child(title_bar_drag_handlers(
|
||||
div()
|
||||
.id("onboarding-drag")
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.h(TABBAR_HEIGHT)
|
||||
.w_full(),
|
||||
window,
|
||||
cx,
|
||||
))
|
||||
.child(
|
||||
v_flex()
|
||||
.size_full()
|
||||
.justify_end()
|
||||
.gap_4()
|
||||
.p_4()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_8()
|
||||
.text_color(cx.theme().icon_muted),
|
||||
)
|
||||
.child(
|
||||
v_flex().child(div().font_semibold().child(TITLE)).child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(DESCRIPTION),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.child(
|
||||
Button::new("join-now")
|
||||
.label("Join now")
|
||||
.primary()
|
||||
.font_semibold()
|
||||
.h_8()
|
||||
.w_full(),
|
||||
)
|
||||
.child(
|
||||
Button::new("import-identity")
|
||||
.label("Import identity")
|
||||
.secondary()
|
||||
.font_semibold()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.on_click(|_event, window, cx| import::open(window, cx)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{App, InteractiveElement, IntoElement, ParentElement, RenderOnce, Styled, Window, div};
|
||||
use theme::ActiveTheme;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::{IconName, Selectable, h_flex};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SidebarTab {
|
||||
Recents,
|
||||
Chats,
|
||||
Communities,
|
||||
}
|
||||
|
||||
impl SidebarTab {
|
||||
pub const ALL: [SidebarTab; 3] = [Self::Recents, Self::Chats, Self::Communities];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Recents => "Recents",
|
||||
Self::Chats => "Chats",
|
||||
Self::Communities => "Communities",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(self) -> IconName {
|
||||
match self {
|
||||
Self::Recents => IconName::History,
|
||||
Self::Chats => IconName::Message,
|
||||
Self::Communities => IconName::Group,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Recents => "sidebar-recents",
|
||||
Self::Chats => "sidebar-chats",
|
||||
Self::Communities => "sidebar-communities",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn index(self) -> usize {
|
||||
match self {
|
||||
Self::Recents => 0,
|
||||
Self::Chats => 1,
|
||||
Self::Communities => 2,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn chat(self) -> bool {
|
||||
matches!(self, Self::Chats)
|
||||
}
|
||||
|
||||
pub fn community(self) -> bool {
|
||||
matches!(self, Self::Communities)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub struct TabBar {
|
||||
active: SidebarTab,
|
||||
on_select: Option<Rc<dyn Fn(SidebarTab, &mut Window, &mut App)>>,
|
||||
}
|
||||
|
||||
impl TabBar {
|
||||
pub fn new(active: SidebarTab) -> Self {
|
||||
Self {
|
||||
active,
|
||||
on_select: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_select(
|
||||
mut self,
|
||||
handler: impl Fn(SidebarTab, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_select = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for TabBar {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let Self { active, on_select } = self;
|
||||
|
||||
div()
|
||||
.id("sidebar-tabs")
|
||||
.absolute()
|
||||
.bottom_3()
|
||||
.left_0()
|
||||
.w_full()
|
||||
.px_4()
|
||||
.child(
|
||||
h_flex()
|
||||
.w_full()
|
||||
.p_1()
|
||||
.gap_1()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().background)
|
||||
.when(cx.theme().shadow, |this| this.shadow_md())
|
||||
.children(SidebarTab::ALL.into_iter().map(|tab| {
|
||||
let on_select = on_select.clone();
|
||||
|
||||
Button::new(format!("tab-{}", tab.list_id()))
|
||||
.icon(tab.icon())
|
||||
.ghost()
|
||||
.flex_1()
|
||||
.rounded()
|
||||
.selected(tab == active)
|
||||
.tooltip(tab.label())
|
||||
.on_click(move |_event, window, cx| {
|
||||
if let Some(on_select) = on_select.as_ref() {
|
||||
on_select(tab, window, cx);
|
||||
}
|
||||
})
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use chat::Room;
|
||||
use community::Community;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
App, ClickEvent, ElementId, Entity, ImageSource, InteractiveElement, IntoElement,
|
||||
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, px,
|
||||
};
|
||||
use settings::AppSettings;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::{Avatar, PixelAvatar};
|
||||
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, h_flex};
|
||||
|
||||
use super::tab::SidebarTab;
|
||||
|
||||
pub enum SidebarRow {
|
||||
Section {
|
||||
label: SharedString,
|
||||
count: usize,
|
||||
},
|
||||
Room {
|
||||
room: Entity<Room>,
|
||||
},
|
||||
Community {
|
||||
community: Entity<Community>,
|
||||
},
|
||||
Action {
|
||||
label: SharedString,
|
||||
tab: SidebarTab,
|
||||
},
|
||||
Hint {
|
||||
text: SharedString,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TreeRowKind {
|
||||
Section,
|
||||
Room,
|
||||
Community,
|
||||
Action,
|
||||
Hint,
|
||||
}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct TreeRow {
|
||||
id: ElementId,
|
||||
kind: TreeRowKind,
|
||||
label: SharedString,
|
||||
avatar: Option<SharedString>,
|
||||
picture: Option<ImageSource>,
|
||||
icon: Option<IconName>,
|
||||
count: Option<usize>,
|
||||
created_at: Option<SharedString>,
|
||||
selected: 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,
|
||||
label: label.into(),
|
||||
avatar: None,
|
||||
picture: None,
|
||||
icon: None,
|
||||
count: None,
|
||||
created_at: None,
|
||||
selected: false,
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<impl Into<ImageSource>>) -> Self {
|
||||
self.picture = picture.map(Into::into);
|
||||
self
|
||||
}
|
||||
|
||||
/// Shows `icon` in the avatar slot when the row has no avatar or picture.
|
||||
pub fn icon(mut self, icon: IconName) -> Self {
|
||||
self.icon = Some(icon);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn count(mut self, count: usize) -> Self {
|
||||
self.count = Some(count);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn created_at(mut self, created_at: impl Into<SharedString>) -> Self {
|
||||
self.created_at = Some(created_at.into());
|
||||
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 Selectable for TreeRow {
|
||||
fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
|
||||
fn is_selected(&self) -> bool {
|
||||
self.selected
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for TreeRow {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
|
||||
let is_section = self.kind == TreeRowKind::Section;
|
||||
let is_room = self.kind == TreeRowKind::Room;
|
||||
let is_community = self.kind == TreeRowKind::Community;
|
||||
let is_action = self.kind == TreeRowKind::Action;
|
||||
let is_hint = self.kind == TreeRowKind::Hint;
|
||||
let is_selected = self.selected;
|
||||
|
||||
let avatar = if hide_avatar {
|
||||
None
|
||||
} else {
|
||||
match (self.avatar, self.picture) {
|
||||
(None, None) => None,
|
||||
(seed, Some(picture)) => Some(
|
||||
Avatar::from_source(picture)
|
||||
.when_some(seed, |avatar, seed| avatar.seed(seed))
|
||||
.xsmall()
|
||||
.flex_shrink_0()
|
||||
.into_any_element(),
|
||||
),
|
||||
(Some(seed), None) => Some(
|
||||
PixelAvatar::new(seed)
|
||||
.xsmall()
|
||||
.flex_shrink_0()
|
||||
.into_any_element(),
|
||||
),
|
||||
}
|
||||
};
|
||||
|
||||
let avatar = avatar.or_else(|| {
|
||||
self.icon.map(|icon| {
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.w(px(20.))
|
||||
.justify_center()
|
||||
.text_color(cx.theme().icon_muted)
|
||||
.child(Icon::new(icon).small())
|
||||
.into_any_element()
|
||||
})
|
||||
});
|
||||
|
||||
h_flex()
|
||||
.id(self.id)
|
||||
.h_8()
|
||||
.w_full()
|
||||
.px_2()
|
||||
.gap_2()
|
||||
.rounded(cx.theme().radius)
|
||||
.when(is_section, |this| {
|
||||
this.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.font_semibold()
|
||||
})
|
||||
.when(is_room || is_community, |this| this.text_sm())
|
||||
.when(is_action, |this| {
|
||||
this.text_sm().text_color(cx.theme().text_muted)
|
||||
})
|
||||
.when(is_hint, |this| {
|
||||
this.text_xs()
|
||||
.font_normal()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
})
|
||||
.when_some(avatar, |this, avatar| this.child(avatar))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.flex_1()
|
||||
.child(
|
||||
div()
|
||||
.truncate()
|
||||
.min_w_0()
|
||||
.when(is_room, |this| this.font_medium())
|
||||
.child(self.label),
|
||||
)
|
||||
.when(is_selected, |this| {
|
||||
this.child(
|
||||
Icon::new(IconName::CheckCircle)
|
||||
.small()
|
||||
.flex_shrink_0()
|
||||
.text_color(cx.theme().icon_accent),
|
||||
)
|
||||
})
|
||||
.when_some(self.count, |this, count| {
|
||||
this.child(div().flex_shrink_0().font_normal().child(count.to_string()))
|
||||
})
|
||||
.when_some(self.created_at, |this, created_at| {
|
||||
this.child(div().flex_1()).child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.text_xs()
|
||||
.child(created_at),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.when_some(self.on_click, |this, handler| {
|
||||
this.cursor_pointer()
|
||||
.when(!is_section, |this| {
|
||||
this.hover(|this| this.bg(cx.theme().ghost_element_hover))
|
||||
})
|
||||
.on_click(move |event, window, cx| handler(event, window, cx))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ common = { path = "../crates/common" }
|
||||
state = { path = "../crates/state" }
|
||||
device = { path = "../crates/device" }
|
||||
chat = { path = "../crates/chat" }
|
||||
community = { path = "../crates/community" }
|
||||
settings = { path = "../crates/settings" }
|
||||
auto_update = { path = "../crates/auto_update" }
|
||||
person = { path = "../crates/person" }
|
||||
|
||||
+35
-30
@@ -9,6 +9,7 @@ use gpui::{
|
||||
use gpui_platform::application;
|
||||
use nostr_sdk::prelude::SecretKey;
|
||||
use state::{APP_ID, CLIENT_NAME};
|
||||
use theme::TABBAR_HEIGHT;
|
||||
use ui::Root;
|
||||
|
||||
actions!(coop, [Quit]);
|
||||
@@ -30,6 +31,12 @@ fn main() {
|
||||
.with_assets(Assets)
|
||||
.with_http_client(Arc::new(reqwest_client::ReqwestClient::new()))
|
||||
.run(move |cx| {
|
||||
// Initialize components
|
||||
ui::init(cx);
|
||||
|
||||
// Initialize theme registry
|
||||
theme::init(cx);
|
||||
|
||||
// Load embedded fonts in assets/fonts
|
||||
load_embedded_fonts(cx);
|
||||
|
||||
@@ -54,6 +61,29 @@ fn main() {
|
||||
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
|
||||
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()),
|
||||
titlebar: Some(TitlebarOptions {
|
||||
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,
|
||||
}),
|
||||
app_owns_titlebar_drag: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Open a window with default options
|
||||
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))
|
||||
})
|
||||
.expect("Failed to open window. Please restart the application.");
|
||||
|
||||
// Bring the app to the foreground
|
||||
cx.activate(true);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
# Community messages panel
|
||||
|
||||
A community opens as a panel in the center dock when its sidebar row is clicked,
|
||||
shaped like every Discord-style client (Vector, Armada):
|
||||
|
||||
```
|
||||
+----------------+----------------------------------+
|
||||
| Channels | |
|
||||
| # general | messages |
|
||||
| # random | |
|
||||
+----------------+ |
|
||||
| Members | |
|
||||
| @alice +----------------------------------+
|
||||
| @bob | [ composer ] |
|
||||
+----------------+----------------------------------+
|
||||
```
|
||||
|
||||
One panel holds both columns. The left column scrolls its two sections; the right
|
||||
column is the timeline plus the composer. The panel is per community, so its
|
||||
`panel_id` is `community-<community_id hex>` and re-clicking a community focuses
|
||||
the existing panel (`ui::dock::add_panel` already does this by `panel_id`).
|
||||
|
||||
## What already exists
|
||||
|
||||
- `sync::planes` already derives a `Plane` for the Control plane, the Guestbook,
|
||||
and every public channel; `CommunityRegistry::sync_subscriptions` subscribes to
|
||||
all of them as one filter, so live channel wraps already reach the client.
|
||||
- `sync::fold` already opens channel wraps and calls `store::cache_rumor`, so the
|
||||
local DB already holds the timeline; it folds the Control Plane and the member
|
||||
list and emits `CommunityEvent::Updated` on every inbound wrap.
|
||||
- `cord03::fold` turns cached rumors into `ChatMessage`s with edits, deletes and
|
||||
reactions resolved; `store::query_rumors` / `store::backfill` are the read paths.
|
||||
- `Community` exposes `channels()`, `members()`, `control()`, `name()`, `icon()`.
|
||||
- `community::init` is already called by `desktop/src/main.rs`.
|
||||
|
||||
So the panel needs no new protocol work: one reader, one writer, and an event that
|
||||
asks the workspace to open the panel.
|
||||
|
||||
## 1. `concord`: a cached rumor is a `ChatRumor`
|
||||
|
||||
`store::query_rumors` hands back `UnsignedEvent`s, and `cord03::typed` (already
|
||||
used by `open`) is private. Add one public wrapper in `crates/concord/src/cords/cord03.rs`:
|
||||
|
||||
```rust
|
||||
/// Rebuild a rumor from a locally-cached copy: the binding tags name its channel and epoch.
|
||||
pub fn parse_rumor(rumor: &UnsignedEvent) -> Result<ChatRumor, ChatError> {
|
||||
let channel: ChannelId = unique_tag(rumor, TAG_CHANNEL)?
|
||||
.ok_or(ChatError::MissingTag(TAG_CHANNEL))?
|
||||
.parse()
|
||||
.map_err(|_| ChatError::BadTag(TAG_CHANNEL))?;
|
||||
|
||||
let epoch = unique_tag(rumor, TAG_EPOCH)?
|
||||
.ok_or(ChatError::MissingTag(TAG_EPOCH))
|
||||
.and_then(|raw| canonical_decimal(&raw).ok_or(ChatError::BadTag(TAG_EPOCH)))?;
|
||||
|
||||
typed(rumor, &channel, Epoch(epoch))
|
||||
}
|
||||
```
|
||||
|
||||
The tags are read with `cord01::unique_tag`, the same reader `check_channel_binding`
|
||||
uses (both become `pub(crate)`), so a cached copy is parsed by exactly the rule that
|
||||
accepted it at ingest. `canonical_decimal` and `typed` are already in this file.
|
||||
|
||||
## 2. `community`: channel history and sending
|
||||
|
||||
All in `crates/community/src/community.rs` on `Community`, mirroring `Room`.
|
||||
|
||||
```rust
|
||||
const MESSAGE_LIMIT: usize = 200;
|
||||
|
||||
/// The secret a channel's plane derives from, and the epoch it is held at.
|
||||
/// A private channel uses the key it was granted; a public one the community root.
|
||||
fn channel_secret(&self, channel: &ChannelId) -> Option<(Epoch, [u8; 32])>;
|
||||
|
||||
/// Page a channel's history into the local cache, once, when the channel is opened.
|
||||
pub fn backfill(&self, channel: &ChannelId, cx: &App) -> Task<Result<()>>;
|
||||
|
||||
/// The channel's timeline, folded from the local cache.
|
||||
pub fn messages(&self, channel: &ChannelId, cx: &App) -> Task<Result<Vec<ChatMessage>>>;
|
||||
|
||||
/// Seal a message to the channel plane, cache it, then publish it to the relays.
|
||||
pub fn send(
|
||||
&self,
|
||||
channel: &ChannelId,
|
||||
content: &str,
|
||||
reply_to: Option<ReplyRef>,
|
||||
cx: &App,
|
||||
) -> Option<Task<Result<EventId>>>;
|
||||
```
|
||||
|
||||
- `backfill`: `store::backfill(&client, channel, &[(epoch, secret)], None, MESSAGE_LIMIT)`,
|
||||
skipped when `store::query_rumors` already finds wraps for the channel, so it runs
|
||||
once per channel. `store::backfill` walks up to `MAX_PAGES` pages itself. It
|
||||
fetches through the client, so the community's relays must be in the pool —
|
||||
`sync_subscriptions` already adds them on load.
|
||||
- `messages`: `store::query_rumors(&client, channel, None, MESSAGE_LIMIT)`, then
|
||||
`cord03::parse_rumor` over each, then `cord03::fold(&rumors, Timestamp::now(), can_delete)`.
|
||||
The closure is the community's own policy:
|
||||
`citation_ok(&owner, &id, actor, citation, &floors) && roles.can_act_on_member(actor, &owner, author, Permissions::MANAGE_MESSAGES)`,
|
||||
built from `self.state.owner`, `self.state.id`, `self.state.floors()` and
|
||||
`self.control.roles` cloned into the background task. `cord03::fold` returns
|
||||
newest-first, so reverse it for the bottom-aligned list.
|
||||
- `send`: `cord03::build_message(author, channel, epoch, content, reply_to.as_ref(), at_ms, timer)`
|
||||
where `timer` is `control.community.message_expiration` and `at_ms` is now in ms;
|
||||
`cord03::seal_rumor(&rumor, &plane, &signer, false)`; then — the order matters —
|
||||
`cord03::open(&wrap, &plane, channel, epoch)` and `store::cache_rumor` *before*
|
||||
publishing, so the author's own row exists whether or not a relay answers. The
|
||||
publish is `pub(crate) sync::connect_relays(client, &relays)` (the
|
||||
`add_relay(..).and_connect()` loop the genesis path already ran) followed by
|
||||
`pub(crate) sync::publish_wrap(client, &wrap, &relays)`, so one copy serves both
|
||||
paths; failures only `log::warn!`. `seal_rumor` needs the channel's `GroupKey`
|
||||
from `derive::channel_group_key(secret, channel, epoch)`, and the epoch from
|
||||
`channel_secret`. Returns `None` without a signer or a held secret, and the rumor
|
||||
id so the panel can reload.
|
||||
|
||||
`CommunityEvent` gains one variant, and the registry a way to request an open,
|
||||
mirroring `ChatRegistry::emit_room`:
|
||||
|
||||
```rust
|
||||
pub enum CommunityEvent {
|
||||
Updated(CommunityId),
|
||||
Open(CommunityId),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
impl CommunityRegistry {
|
||||
/// Ask the workspace to open a community's panel.
|
||||
pub fn emit_community(&mut self, community: &Entity<Community>, window: &mut Window, cx: &mut Context<Self>);
|
||||
}
|
||||
```
|
||||
|
||||
`emit_community` reads the id and emits `CommunityEvent::Open` through
|
||||
`cx.defer_in(window, ...)` so the click never re-enters the registry.
|
||||
|
||||
Private channels stay out of this pass: `sync::planes` does not subscribe them and
|
||||
`CommunityState` has no room for a rotated key yet, so `channel_secret` returning
|
||||
the granted `key` is the only support they get.
|
||||
|
||||
## 3. `community_ui`: the new crate
|
||||
|
||||
`crates/community_ui`, shaped like `chat_ui` (which is the reference for every
|
||||
detail: `Panel` impl, notification routing, input handling, message list).
|
||||
|
||||
```
|
||||
crates/community_ui/Cargo.toml deps: community, state, ui, theme, common, person, settings, gpui, nostr-sdk, smallvec, anyhow, log
|
||||
crates/community_ui/src/lib.rs init + CommunityPanel
|
||||
crates/community_ui/src/message.rs one message row's rendering
|
||||
```
|
||||
|
||||
`community` re-exports the types the panel names, as `chat` already does for
|
||||
`Message`: `ChatMessage`, `ReplyRef`, `ChannelId`, `CommunityId`.
|
||||
|
||||
```rust
|
||||
pub fn init(community: Entity<Community>, window: &mut Window, cx: &mut App) -> Entity<CommunityPanel>;
|
||||
|
||||
pub struct CommunityPanel {
|
||||
id: SharedString, // "community-<hex>"
|
||||
focus_handle: FocusHandle,
|
||||
community: WeakEntity<Community>,
|
||||
channel: Option<ChannelId>, // the selected channel
|
||||
messages: Vec<ChatMessage>, // ascending, bottom-aligned list
|
||||
list_state: ListState,
|
||||
input: Entity<TextareaState>,
|
||||
tasks: Vec<Task<Result<()>>>,
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
```
|
||||
|
||||
- `new` takes the strong `Entity<Community>`, subscribes with
|
||||
`cx.subscribe_in(&community, window, ...)` while it has it, and keeps only the
|
||||
weak handle afterwards (`ChatPanel::subscribe_room_events` is the same split).
|
||||
It picks `channels().first()` (the genesis `#general`) and, in the subscription,
|
||||
`CommunityEvent::Updated(id)` reloads the open channel through `cx.defer_in`,
|
||||
because the emit sits inside the community's own update; `CommunityEvent::Error(error)`
|
||||
becomes a window notification. A `cx.defer_in` does the first `backfill` + `messages`
|
||||
load, exactly as `ChatPanel::new` defers `connect`.
|
||||
- The channel and member lists are read live in `render` through the weak entity
|
||||
(as the sidebar reads `Community::channels()`), so a new channel or member needs no
|
||||
invalidation; a dropped entity renders an empty state instead.
|
||||
- `select_channel(channel, window, cx)` swaps the selection, resets the list and
|
||||
loads: `backfill` once per channel, then `messages`.
|
||||
- `reload(cx)` awaits `community.messages(&channel, cx)`, replaces `messages` and
|
||||
`list_state.reset(len)` (then `scroll_to_end`). Edits, deletes and reactions are
|
||||
folded server-side of the UI, so a full replace is the honest update and stays
|
||||
small at `MESSAGE_LIMIT`. A failed load becomes a window notification.
|
||||
- `send(window, cx)` reads `self.input`, calls `community.send(...)`, clears the
|
||||
input, and reloads when the task resolves. Empty input is refused with a
|
||||
notification, like `ChatPanel`.
|
||||
- `render`: `v_flex` holding `h_flex`
|
||||
- left: `w(px(220.))`, `border_r_1`, `.overflow_y_scrollbar()` column with a
|
||||
`Channels` section (row = icon `IconName::Message`, or `Lock` when private, plus
|
||||
`ChannelKeyRef.name`; the selected row takes `cx.theme().ghost_element_selected`) and
|
||||
a `Members` section (row = `Avatar` from
|
||||
`PersonRegistry::global(cx).read(cx).get(&pk, cx)` plus the profile name,
|
||||
honouring `AppSettings::get_hide_avatar` like `TreeRow`).
|
||||
- right: `v_flex().flex_1().min_w_0()` with `gpui::list(self.list_state, ...)` over
|
||||
`message::render(...)` and `Scrollbar::vertical(&self.list_state)`, then the
|
||||
composer row: `Textarea` (`InputEvent::PressEnter` sends) and a
|
||||
`Button::new("send").icon(IconName::PaperPlaneFill)`.
|
||||
- A message row: author name (person profile, "Unknown" fallback), `to_ago()` from
|
||||
`common::TimestampExt`, the content as plain text (no markdown, media or file
|
||||
rendering in this pass), a muted `(edited)` marker when `edited_at` is set, an
|
||||
emoji summary line from `reactions`, and `"Message deleted"` in
|
||||
`cx.theme().text_placeholder` when `deleted`.
|
||||
- `Panel`: `panel_id` = the id above, `title` = the community icon (`Avatar`) plus
|
||||
`community.name()`, `closable` = true, no toolbar buttons.
|
||||
|
||||
## 4. `workspace`: open the panel from the sidebar
|
||||
|
||||
- `crates/workspace/Cargo.toml`: add `community_ui = { path = "../community_ui" }`.
|
||||
- `crates/workspace/src/lib.rs`: subscribe to `CommunityRegistry` beside the chat
|
||||
subscription and, on `CommunityEvent::Open(id)`, look the community up with
|
||||
`registry.read(cx).community(&id)` and
|
||||
`add_panel_to_dock(community_ui::init(community, window, cx), DockPlacement::Center, window, cx)`.
|
||||
`CommunityEvent::Error` keeps its single handler in the sidebar.
|
||||
- `crates/workspace/src/sidebar/mod.rs`: `open_community` keeps recording the
|
||||
recent community and now ends with
|
||||
`CommunityRegistry::global(cx).update(cx, |registry, cx| registry.emit_community(&community, window, cx))`,
|
||||
so the row's click handler needs the `window`.
|
||||
|
||||
## 5. Order of work
|
||||
|
||||
1. `cord03::parse_rumor`.
|
||||
2. `community`: `channel_secret`, `backfill`, `messages`, `send`, `CommunityEvent::Open`,
|
||||
`emit_community`.
|
||||
3. `community_ui`: `message.rs`, then the panel with the channel list, the timeline
|
||||
and the composer, then the member list.
|
||||
4. `workspace`: the dependency, the registry subscription, the sidebar click.
|
||||
5. `cargo check -p workspace` (the panel only compiles through it), then a manual
|
||||
run: create a community, click its sidebar row, send a message and see it through
|
||||
a second account.
|
||||
|
||||
No tests: the crate follows the "no `unwrap`, errors to the UI" rule and validation
|
||||
is the manual run above.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Files, reactions as a composer action, edits, threads, pins, typing indicators,
|
||||
unread badges, notifications, message expiration purging (`store::purge_expired`),
|
||||
private-channel subscriptions (a rekey cannot be persisted yet), moderation actions,
|
||||
and community management (metadata, roles, invites). Also unchanged:
|
||||
`crates/chat/src/lib.rs::handle_notifications` already routes kind 1059 wraps by
|
||||
subscription id, so concord traffic does not land in the DM trash.
|
||||
+151
-55
@@ -47,7 +47,7 @@ use concord::cord02::{self, CommunityMetadata};
|
||||
use concord::store::{self, CommunityState, save_state};
|
||||
|
||||
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.wraps — the two owner-signed genesis editions, already sealed
|
||||
@@ -73,12 +73,18 @@ let editions: Vec<ParsedEdition> = minted
|
||||
.collect::<Result<_, _>>()?;
|
||||
|
||||
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
|
||||
client explicitly — coop's client is a gossip client with no background refresh.
|
||||
|
||||
Creating is not finished until the membership is announced. The two writes are
|
||||
independent and both best-effort: the genesis wraps go to the community's
|
||||
relays, and the membership goes to the account's own Community List (below), so a
|
||||
new device — or another client — can find the community without an invite.
|
||||
`crates/community`'s `sync::create` performs both.
|
||||
|
||||
## Joining
|
||||
|
||||
An invite link resolves to a bundle:
|
||||
@@ -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:
|
||||
|
||||
```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`,
|
||||
@@ -114,7 +120,7 @@ use concord::cord02::guestbook;
|
||||
|
||||
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 (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?;
|
||||
```
|
||||
|
||||
@@ -159,7 +165,7 @@ use concord::derive::channel_group_key;
|
||||
|
||||
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 (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?;
|
||||
```
|
||||
|
||||
@@ -192,7 +198,7 @@ for wrap in &wraps {
|
||||
let Ok((opened, rumor)) = cord03::open(wrap, group, &channel, *epoch) else {
|
||||
continue;
|
||||
};
|
||||
store::cache_rumor(database, &channel, &opened).await?;
|
||||
store::cache_rumor(&client, &channel, &opened).await?;
|
||||
rumors.push(rumor);
|
||||
}
|
||||
|
||||
@@ -209,13 +215,13 @@ let messages = fold(&rumors, Timestamp::now(), |actor, citation, author| {
|
||||
Relay history pages through the local cache:
|
||||
|
||||
```rust
|
||||
let page = store::backfill(client, database, &channel, &held, until, 50).await?;
|
||||
let cached = store::query_rumors(database, &channel, None, 50).await?;
|
||||
let page = store::backfill(client, &channel, &held, until, 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
|
||||
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
|
||||
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 (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
|
||||
@@ -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 content = cord04::pins::publishable(&read, channel_is_private, &plane, epoch)?;
|
||||
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
|
||||
@@ -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:
|
||||
|
||||
```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 {
|
||||
token: HEXLOWER.encode(&token),
|
||||
signer_sk: link_signer.secret_key().to_secret_hex(),
|
||||
@@ -319,7 +325,7 @@ list.entries.push(InviteEntry {
|
||||
expires_at: None,
|
||||
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.
|
||||
list.tombstones.push(InviteTombstone {
|
||||
@@ -356,12 +362,16 @@ let (control_pk, control_root) = match scope {
|
||||
RekeyScope::Channel(_) => (None, None),
|
||||
};
|
||||
|
||||
let blobs = members
|
||||
.iter()
|
||||
.map(|member| {
|
||||
cord06::build_blob(&my_keys, member, scope, plan.epoch, &new_key, control_pk.as_ref(), control_root)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let mut blobs = Vec::with_capacity(members.len());
|
||||
|
||||
for member in &members {
|
||||
blobs.push(
|
||||
cord06::build_blob(
|
||||
&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 wraps = cord06::build_rekey_chunks(
|
||||
@@ -375,7 +385,8 @@ let wraps = cord06::build_rekey_chunks(
|
||||
citation,
|
||||
false,
|
||||
now_secs,
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
```
|
||||
|
||||
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`
|
||||
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:
|
||||
|
||||
```rust
|
||||
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.
|
||||
if cord06::verify_dissolved(&wrap, &identity) {
|
||||
@@ -404,22 +419,77 @@ A member's own memberships, synced across their devices:
|
||||
```rust
|
||||
use concord::cord02::list;
|
||||
|
||||
let material = cord02::list::join_material(&invite, staff.then_some(&control_root));
|
||||
let mut mine = cord02::list::parse_list_event(&my_keys, &event)?;
|
||||
mine = cord02::list::merge(mine, cord02::list::CommunityList {
|
||||
entries: vec![cord02::list::CommunityListEntry { community_id, seed: material.clone(), current: material, added_at: now_ms, extra: Default::default() }],
|
||||
..Default::default()
|
||||
});
|
||||
let event = cord02::list::build_list_event(&my_keys, &mine)?; // kind 13302, NIP-44 to self
|
||||
let entry = concord::store::list_entry(&state, &metadata.name); // state → §8 material
|
||||
let held = list::parse_list_event(&my_keys, &event).await?; // validates the d tag
|
||||
let mine = held.joined(entry); // community_id-keyed union
|
||||
let event = list::build_list_event(&my_keys, &mine, 0, now_secs).await?; // kind 33302, d = 0
|
||||
client.send_event(&event).to_nip65().await?; // account's own write relays
|
||||
```
|
||||
|
||||
The publish needs no separate database write: `send_event` persists the event
|
||||
locally *before* it resolves targets, so the fragment is available to the
|
||||
`concord/list` read path even if every relay is unreachable.
|
||||
|
||||
`join_material(&invite, control_root)` makes the §8 material from a CORD-05
|
||||
invite; `store::list_entry(state, name)` makes it from a `CommunityState`, and is
|
||||
what a write path uses after a create, join or rename. `joined` and `tombstoned`
|
||||
are the two mutations: both are `community_id`-keyed unions, so neither an append
|
||||
nor a leave can lose a membership the other writer has.
|
||||
|
||||
The entry is signed by the member's real key and sealed to that same key
|
||||
(`seal_to_self`), so only the member's devices read it — a stranger's
|
||||
`parse_list_event` fails rather than returning a partial list. Publishing goes to
|
||||
the member's **NIP-65 write relays**, the same set the `concord/list`
|
||||
subscription resolves for its `author` filter.
|
||||
|
||||
Kind `33302` is **addressable and fragmented**: one event per fragment, its `d`
|
||||
tag the fragment index in decimal. `frags` in the payload declares how many the
|
||||
List has, and `is_complete(held_indices)` answers whether the client has a
|
||||
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
|
||||
strictly newer join outruns it. `fits()` is the write gate — 50 memberships and
|
||||
the NIP-44 size cap, both protocol constants.
|
||||
strictly newer join outruns it. `fits()` is the write gate: 50 memberships and
|
||||
the NIP-44 plaintext cap. The 50 is a stopgap inherited from the retired
|
||||
single-event design — §8 has **no membership limit**, its only bound is the
|
||||
65,536-byte encoded event, and the real fix is to start a new fragment on write.
|
||||
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
|
||||
|
||||
`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
|
||||
the foreground thread.
|
||||
|
||||
@@ -428,13 +498,13 @@ the foreground thread.
|
||||
Same shape as `ChatRegistry`:
|
||||
|
||||
```rust
|
||||
pub fn init(window: &mut Window, cx: &mut App) {
|
||||
ConcordRegistry::set_global(cx.new(|cx| ConcordRegistry::new(window, cx)), cx);
|
||||
pub fn init(cx: &mut App) {
|
||||
CommunityRegistry::set_global(cx.new(CommunityRegistry::new), cx);
|
||||
}
|
||||
|
||||
impl ConcordRegistry {
|
||||
impl CommunityRegistry {
|
||||
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
|
||||
the account.
|
||||
|
||||
- `ConcordRegistry` holds `communities: Vec<Entity<Community>>`, an index by
|
||||
`CommunityId`, and `tasks: SmallVec<[Task<Result<(), Error>>; 2]>`.
|
||||
- `CommunityRegistry` holds `communities: Vec<Entity<Community>>`, an index by
|
||||
`CommunityId`, and `tasks: SmallVec<[Task<Result<()>>; 2]>`.
|
||||
- `Community` owns one `CommunityState`, the last `ControlFold`, the member list
|
||||
and the channel list. Views render `Entity<Community>`; no protocol state
|
||||
lives in a view.
|
||||
@@ -459,7 +529,7 @@ A background task never touches an entity. It sends results through a bounded
|
||||
|
||||
```rust
|
||||
let (signal_tx, signal_rx) = flume::bounded::<Signal>(256);
|
||||
let database = client.database().clone();
|
||||
let client = client.clone();
|
||||
|
||||
// Background: open, verify, fold — no entities.
|
||||
self.ingress = Some(cx.background_spawn(async move {
|
||||
@@ -468,7 +538,7 @@ self.ingress = Some(cx.background_spawn(async move {
|
||||
continue;
|
||||
};
|
||||
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?;
|
||||
}
|
||||
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`
|
||||
wants `&dyn NostrDatabase`, so clone the `Arc` and pass `database.as_ref()`.
|
||||
- Every store function takes the `&Client` and reaches the database through
|
||||
`client.database()`, so clone the `Client` into the background task.
|
||||
- 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
|
||||
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
|
||||
first relay request.
|
||||
- NIP-46 signing is async: call `signer.get_public_key_async()` /
|
||||
`sign_event_async` inside the background task. The builders still take
|
||||
`&Keys`, so run them where device keys are available.
|
||||
`sign_event_async` inside the background task. Every account-key writer takes
|
||||
any signer (`Keys` or the app's `UniversalSigner`) and is `async`, so `await`
|
||||
it there rather than requiring device keys.
|
||||
|
||||
### Subscriptions
|
||||
|
||||
@@ -527,19 +598,44 @@ client.subscribe(filter).with_id(sub_id).await?;
|
||||
|
||||
## Not wired up yet
|
||||
|
||||
- **No registry and no sync engine.** `crates/concord` has no subscriptions, no
|
||||
`init`, and no `Entity<Community>`; the UI owns subscribing, routing a wrap to
|
||||
the plane whose address it carries, and rebuilding a subscription when a plane's
|
||||
address changes (join, channel added, rekey folded). GPUI integration above is
|
||||
the shape to build, not code that exists.
|
||||
- **Every writer takes `&Keys`, not a `NostrSigner`.** NIP-46 is one deliberate
|
||||
pass over the builders, not a per-call patch.
|
||||
- **`crates/concord` stays protocol-only; the registry lives in
|
||||
`crates/community`.** `concord` has no subscriptions, no `init`, and no
|
||||
`Entity<Community>`; `community::CommunityRegistry` owns one `Entity<Community>`
|
||||
per state document, subscribes when a community's plane set changes, and
|
||||
re-folds on an inbound wrap. The sidebar subscribes to the registry, surfaces
|
||||
`CommunityEvent::Error` as a window notification, and its "New community" row
|
||||
in the Communities tab dispatches `Command::NewCommunity`, whose name prompt
|
||||
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
|
||||
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
|
||||
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
|
||||
key a rotation delivered and `ChannelKeyRef` carries no key of its own, so a
|
||||
client can verify a rotation and still lose it on restart — history under a
|
||||
prior root or a prior channel epoch is unreadable until that schema change
|
||||
lands.
|
||||
- **Rotation-delivered plane keys cannot be persisted yet.** `CommunityState` has
|
||||
nowhere to keep a key a rotation delivered, so a client can verify a rotation
|
||||
and still lose it on restart — history under a prior root or a prior channel
|
||||
epoch is unreadable until that schema change lands. (A granted private-channel
|
||||
key does now have a home: `ChannelKeyRef.key`, filled by
|
||||
`CommunityState::from_join_material`.)
|
||||
|
||||
@@ -16,6 +16,7 @@ common = { path = "../crates/common" }
|
||||
state = { path = "../crates/state" }
|
||||
device = { path = "../crates/device" }
|
||||
chat = { path = "../crates/chat" }
|
||||
community = { path = "../crates/community" }
|
||||
settings = { path = "../crates/settings" }
|
||||
person = { path = "../crates/person" }
|
||||
|
||||
|
||||
+26
-25
@@ -47,32 +47,33 @@ pub fn run() -> Result<(), JsValue> {
|
||||
};
|
||||
|
||||
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| {
|
||||
// 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))
|
||||
})
|
||||
.expect("Failed to open window. Please restart the application.");
|
||||
|
||||
Reference in New Issue
Block a user