feat: add support for NIP-29 #56
Generated
+42
-39
@@ -1276,41 +1276,6 @@ dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "community"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"concord",
|
||||
"flume 0.11.1",
|
||||
"futures",
|
||||
"gpui-pre",
|
||||
"log",
|
||||
"nostr-memory",
|
||||
"nostr-sdk",
|
||||
"serde_json",
|
||||
"smallvec",
|
||||
"smol",
|
||||
"state",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "community_ui"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"common",
|
||||
"community",
|
||||
"gpui-pre",
|
||||
"nostr-sdk",
|
||||
"person",
|
||||
"settings",
|
||||
"smallvec",
|
||||
"state",
|
||||
"theme",
|
||||
"ui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.43"
|
||||
@@ -1449,7 +1414,6 @@ dependencies = [
|
||||
"auto_update",
|
||||
"chat",
|
||||
"common",
|
||||
"community",
|
||||
"device",
|
||||
"gpui-pre",
|
||||
"gpui-pre-linux",
|
||||
@@ -1458,6 +1422,7 @@ dependencies = [
|
||||
"gpui-pre-reqwest-client",
|
||||
"gpui-pre-windows",
|
||||
"log",
|
||||
"nip29",
|
||||
"nostr-sdk",
|
||||
"person",
|
||||
"settings",
|
||||
@@ -1475,7 +1440,6 @@ dependencies = [
|
||||
"assets",
|
||||
"chat",
|
||||
"common",
|
||||
"community",
|
||||
"console_error_panic_hook",
|
||||
"console_log",
|
||||
"device",
|
||||
@@ -1487,6 +1451,7 @@ dependencies = [
|
||||
"gpui-pre-web",
|
||||
"instant",
|
||||
"log",
|
||||
"nip29",
|
||||
"person",
|
||||
"settings",
|
||||
"state",
|
||||
@@ -4593,6 +4558,44 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nip29"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chat",
|
||||
"flume 0.11.1",
|
||||
"futures",
|
||||
"gpui-pre",
|
||||
"log",
|
||||
"nostr",
|
||||
"nostr-sdk",
|
||||
"serde_json",
|
||||
"smallvec",
|
||||
"state",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nip29_ui"
|
||||
version = "1.0.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chat",
|
||||
"chat_ui",
|
||||
"common",
|
||||
"gpui-pre",
|
||||
"log",
|
||||
"nip29",
|
||||
"nostr-sdk",
|
||||
"person",
|
||||
"serde",
|
||||
"settings",
|
||||
"smallvec",
|
||||
"state",
|
||||
"theme",
|
||||
"ui",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "no_std_io2"
|
||||
version = "0.9.4"
|
||||
@@ -9479,12 +9482,12 @@ dependencies = [
|
||||
"chat",
|
||||
"chat_ui",
|
||||
"common",
|
||||
"community",
|
||||
"community_ui",
|
||||
"device",
|
||||
"gpui-pre",
|
||||
"instant",
|
||||
"log",
|
||||
"nip29",
|
||||
"nip29_ui",
|
||||
"nostr-connect",
|
||||
"nostr-sdk",
|
||||
"person",
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ nostr-blossom = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] }
|
||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44", "nip98" ] }
|
||||
|
||||
# Crypto (NIP-17 encrypted file messages)
|
||||
aes-gcm = "0.10"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M21.25 12C21.25 17.1086 17.1086 21.25 12 21.25C9.44568 21.25 7.13318 20.2147 5.45926 18.5407C3.78534 16.8668 2.75 14.5543 2.75 12C2.75 6.89137 6.89137 2.75 12 2.75C14.5543 2.75 16.8668 3.78534 18.5407 5.45926C20.2147 7.13318 21.25 9.44568 21.25 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="square"/><path d="M18 6L6 18" stroke="currentColor" stroke-width="1.5" stroke-linecap="square"/> </svg>
|
||||
|
After Width: | Height: | Size: 493 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M12.75 11.2499V8.24994L17.3358 3.66416C18.1168 2.88311 19.3832 2.88311 20.1642 3.66416L20.3358 3.83573C21.1168 4.61678 21.1168 5.88311 20.3358 6.66416L15.75 11.2499H12.75Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M11.25 3.75H6.95C5.8299 3.75 5.26984 3.75 4.84202 3.96799C4.46569 4.15973 4.15973 4.46569 3.96799 4.84202C3.75 5.26984 3.75 5.8299 3.75 6.95V17.05C3.75 18.1701 3.75 18.7302 3.96799 19.158C4.15973 19.5343 4.46569 19.8403 4.84202 20.032C5.26984 20.25 5.8299 20.25 6.95 20.25H17.05C18.1701 20.25 18.7302 20.25 19.158 20.032C19.5343 19.8403 19.8403 19.5343 20.032 19.158C20.25 18.7302 20.25 18.1701 20.25 17.05V12.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> </svg>
|
||||
|
After Width: | Height: | Size: 857 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M14.75 3.75L18.25 3.75C19.3546 3.75 20.25 4.64543 20.25 5.75V18.25C20.25 19.3546 19.3546 20.25 18.25 20.25H14.75M15 12H3.75M15 12L11.5 15.5M15 12L11.5 8.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> </svg>
|
||||
|
After Width: | Height: | Size: 335 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M20.25 12L9 12M20.25 12L15.75 16.5M20.25 12L15.75 7.5M11.25 20.25H5.75C4.64543 20.25 3.75 19.3546 3.75 18.25L3.75 5.75C3.75 4.64543 4.64543 3.75 5.75 3.75L11.25 3.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> </svg>
|
||||
|
After Width: | Height: | Size: 346 B |
@@ -1,3 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 19.25V13L14.5 15.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 13L9.5 15.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.375 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V5.75C2.75 4.64543 3.64543 3.75 4.75 3.75H8.92963C9.59834 3.75 10.2228 4.0842 10.5937 4.6406L11.7031 6.3047C11.8886 6.5829 12.2008 6.75 12.5352 6.75H19.25C20.3546 6.75 21.25 7.64543 21.25 8.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H16.625" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M11.25 20.25H5.75C4.64543 20.25 3.75 19.3546 3.75 18.25V5.75C3.75 4.64543 4.64543 3.75 5.75 3.75H18.25C19.3546 3.75 20.25 4.64543 20.25 5.75V11.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M3.75 16.25L6.58579 13.4142C7.36683 12.6332 8.63317 12.6332 9.41421 13.4142L12 16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M14.5 11.5C15.6046 11.5 16.5 10.6046 16.5 9.5C16.5 8.39543 15.6046 7.5 14.5 7.5C13.3954 7.5 12.5 8.39543 12.5 9.5C12.5 10.6046 13.3954 11.5 14.5 11.5Z" stroke="currentColor" stroke-width="1.5"/><path d="M15.75 18.5H21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M18.5 15.75V21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> </svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 718 B After Width: | Height: | Size: 849 B |
@@ -53,7 +53,7 @@ static EMOJI_RE: LazyLock<Regex> =
|
||||
|
||||
mod actions;
|
||||
mod file;
|
||||
mod text;
|
||||
pub mod text;
|
||||
|
||||
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
|
||||
cx.new(|cx| ChatPanel::new(room, window, cx))
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use concord::cord01::OpenedStream;
|
||||
use concord::state::{CommunityState, STATE_PREFIX, state_identifier};
|
||||
use concord::{ChannelId, CommunityId, cord03};
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(|| {
|
||||
Keys::new(SecretKey::from_slice(&[0x43; 32]).expect("a fixed 32-byte scalar is a valid key"))
|
||||
});
|
||||
|
||||
const CHANNEL_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_C;
|
||||
const MARK_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_T;
|
||||
const MARK_VALUE: &str = "concord";
|
||||
const WRAP_TAG: &str = "e";
|
||||
const KIND_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_K;
|
||||
|
||||
/// An already-expired rumor is refused at ingest. Returns whether it was kept.
|
||||
pub async fn cache_rumor(
|
||||
client: &Client,
|
||||
channel: &ChannelId,
|
||||
opened: &OpenedStream,
|
||||
) -> Result<bool> {
|
||||
let at = Timestamp::from_secs(opened.at_ms / 1000);
|
||||
|
||||
if cord03::expiration_of(&opened.rumor)?
|
||||
.is_some_and(|expiration| expiration <= Timestamp::now())
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let tags = vec![
|
||||
Tag::identifier(opened.rumor_id),
|
||||
Tag::custom(KIND_TAG.as_str(), [opened.rumor.kind.to_string()]),
|
||||
Tag::custom(WRAP_TAG, [opened.wrapper_id.to_string()]),
|
||||
Tag::custom(MARK_TAG.as_str(), [MARK_VALUE]),
|
||||
Tag::custom(CHANNEL_TAG.as_str(), [channel.to_hex()]),
|
||||
Tag::public_key(opened.author),
|
||||
];
|
||||
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, opened.rumor.as_json())
|
||||
.tags(tags)
|
||||
.custom_created_at(at)
|
||||
.finalize_async(&*LOCAL_KEYS)
|
||||
.await?;
|
||||
|
||||
client.database().save_event(&event).await?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn purge_expired(client: &Client, channel: &ChannelId, now: Timestamp) -> Result<usize> {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(MARK_TAG, MARK_VALUE)
|
||||
.custom_tag(CHANNEL_TAG, channel.to_hex());
|
||||
|
||||
let mut expired = Vec::new();
|
||||
|
||||
for event in client.database().query(filter).await? {
|
||||
let Ok(rumor) = UnsignedEvent::from_json(&event.content) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Ok(Some(expiration)) = cord03::expiration_of(&rumor) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if expiration <= now {
|
||||
expired.push(event.id);
|
||||
}
|
||||
}
|
||||
|
||||
let purged = expired.len();
|
||||
|
||||
if purged > 0 {
|
||||
client.database().delete(Filter::new().ids(expired)).await?;
|
||||
}
|
||||
|
||||
Ok(purged)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Observed {
|
||||
pub author: PublicKey,
|
||||
pub at_ms: u64,
|
||||
}
|
||||
|
||||
/// The cached rumors of `channel`, keyed by the wrap they were opened from.
|
||||
pub async fn wrapper_index(
|
||||
client: &Client,
|
||||
channel: &ChannelId,
|
||||
) -> Result<BTreeMap<EventId, Observed>> {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(MARK_TAG, MARK_VALUE)
|
||||
.custom_tag(CHANNEL_TAG, channel.to_hex());
|
||||
|
||||
let mut index = BTreeMap::new();
|
||||
|
||||
for event in client.database().query(filter).await? {
|
||||
let (Some(wrapper_id), Some(author)) = (
|
||||
event.tags.event_ids().next(),
|
||||
event.tags.public_keys().next(),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
index.insert(
|
||||
wrapper_id,
|
||||
Observed {
|
||||
author,
|
||||
at_ms: event.created_at.as_secs().saturating_mul(1000),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
/// Cached rumors for `channel`, newest first, deduplicated by rumor id.
|
||||
pub async fn query_rumors(
|
||||
client: &Client,
|
||||
channel: &ChannelId,
|
||||
until: Option<Timestamp>,
|
||||
limit: usize,
|
||||
kinds: Option<&[u16]>,
|
||||
) -> Result<Vec<UnsignedEvent>> {
|
||||
let mut filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(MARK_TAG, MARK_VALUE)
|
||||
.custom_tag(CHANNEL_TAG, channel.to_hex());
|
||||
|
||||
if let Some(kinds) = kinds {
|
||||
filter = filter.custom_tags(KIND_TAG, kinds.iter().map(u16::to_string));
|
||||
}
|
||||
|
||||
if let Some(until) = until {
|
||||
filter = filter.until(until);
|
||||
}
|
||||
|
||||
let mut newest: BTreeMap<String, Event> = BTreeMap::new();
|
||||
for event in client.database().query(filter).await? {
|
||||
let Some(rumor_id) = event.tags.identifier() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match newest.get(&rumor_id) {
|
||||
Some(existing) if existing.created_at >= event.created_at => {}
|
||||
_ => {
|
||||
newest.insert(rumor_id, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut events: Vec<Event> = newest.into_values().collect();
|
||||
events.sort_by_key(|event| std::cmp::Reverse(event.created_at));
|
||||
events.truncate(limit);
|
||||
|
||||
let mut rumors = Vec::with_capacity(events.len());
|
||||
for event in events {
|
||||
let rumor = UnsignedEvent::from_json(event.content)
|
||||
.map_err(|error| anyhow!("cached rumor is not a valid event: {error}"))?;
|
||||
rumors.push(rumor);
|
||||
}
|
||||
|
||||
Ok(rumors)
|
||||
}
|
||||
|
||||
pub async fn save_state(client: &Client, state: &CommunityState) -> Result<()> {
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
|
||||
.tags([Tag::identifier(state.identifier())])
|
||||
.finalize_async(&*LOCAL_KEYS)
|
||||
.await?;
|
||||
|
||||
client.database().save_event(&event).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load_state(client: &Client, id: &CommunityId) -> Result<Option<CommunityState>> {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(state_identifier(id))
|
||||
.limit(1);
|
||||
|
||||
match client.database().query(filter).await?.into_iter().next() {
|
||||
Some(event) => Ok(Some(serde_json::from_str(&event.content)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The newest state document per community carried in the local database.
|
||||
pub async fn load_states(client: &Client) -> Result<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()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use concord::Epoch;
|
||||
use nostr_memory::MemoryDatabase;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn client() -> Client {
|
||||
ClientBuilder::default()
|
||||
.database(MemoryDatabase::unbounded())
|
||||
.build()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_states_reads_one_document_per_community_and_ignores_other_documents() {
|
||||
smol::block_on(async {
|
||||
let client = client();
|
||||
|
||||
let state = CommunityState {
|
||||
id: CommunityId::from_bytes([0x42; 32]),
|
||||
name: Some("Anime and Manga".to_owned()),
|
||||
owner: Keys::generate().public_key(),
|
||||
owner_salt: [0x01; 32],
|
||||
community_root: [0x02; 32],
|
||||
root_epoch: Epoch(0),
|
||||
control_root: None,
|
||||
control_pks: BTreeMap::new(),
|
||||
channels: Vec::new(),
|
||||
relays: Vec::new(),
|
||||
heads: Vec::new(),
|
||||
banned: BTreeSet::new(),
|
||||
cursors: BTreeMap::new(),
|
||||
held_roots: Vec::new(),
|
||||
channel_cuts: BTreeMap::new(),
|
||||
refounders: BTreeSet::new(),
|
||||
removed_at: None,
|
||||
stranded: false,
|
||||
dissolved: false,
|
||||
added_at_ms: 7,
|
||||
};
|
||||
|
||||
save_state(&client, &state).await.expect("saves");
|
||||
|
||||
// A cached rumor is also an application-specific document, but not a
|
||||
// state document, so the prefix keeps it out of the state scan.
|
||||
let other = EventBuilder::new(Kind::ApplicationSpecificData, "{}")
|
||||
.tags([Tag::identifier("deadbeef")])
|
||||
.finalize(&*LOCAL_KEYS)
|
||||
.expect("builds");
|
||||
client.database().save_event(&other).await.expect("saves");
|
||||
|
||||
let loaded = load_states(&client).await.expect("loads");
|
||||
|
||||
assert_eq!(loaded, vec![state]);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caching_the_same_rumor_twice_leaves_one_row() {
|
||||
smol::block_on(async {
|
||||
let client = client();
|
||||
let channel = ChannelId::from_bytes([0x9c; 32]);
|
||||
let keys = Keys::generate();
|
||||
let group = concord::derive::channel_group_key(&[0x07; 32], &channel, Epoch(0))
|
||||
.expect("a group key");
|
||||
|
||||
let rumor = concord::cord03::build_message(
|
||||
keys.public_key(),
|
||||
&channel,
|
||||
Epoch(0),
|
||||
"twice",
|
||||
None,
|
||||
1_700_000_000_000,
|
||||
None,
|
||||
);
|
||||
let (wrap, _) = concord::cord03::seal_rumor(&rumor, &group, &keys, false)
|
||||
.await
|
||||
.expect("seals");
|
||||
let (opened, _) =
|
||||
concord::cord03::open(&wrap, &group, &channel, Epoch(0)).expect("opens");
|
||||
|
||||
assert!(
|
||||
cache_rumor(&client, &channel, &opened)
|
||||
.await
|
||||
.expect("caches")
|
||||
);
|
||||
assert!(
|
||||
cache_rumor(&client, &channel, &opened)
|
||||
.await
|
||||
.expect("caches")
|
||||
);
|
||||
|
||||
let cached = query_rumors(&client, &channel, None, 10, None)
|
||||
.await
|
||||
.expect("reads");
|
||||
assert_eq!(cached.len(), 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,764 +0,0 @@
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::future::Future;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use concord::cord01::{KIND_WRAP_EPHEMERAL, OpenedStream};
|
||||
use concord::cord03::{self, ChatRumor};
|
||||
use concord::derive::channel_group_key;
|
||||
use concord::state::{ChannelCursor, HeldKey};
|
||||
use concord::{ChannelId, GroupKey};
|
||||
use futures::future::{Either, select};
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::cache::cache_rumor;
|
||||
use crate::sync::connect_relays;
|
||||
|
||||
/// How long one relay is given to answer one page of history.
|
||||
const PAGE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// How far below a cursor a warm window reaches back.
|
||||
pub const CURSOR_OVERLAP: Duration = Duration::from_secs(60);
|
||||
|
||||
/// The region of history to read.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct Window {
|
||||
pub until: Option<Timestamp>,
|
||||
pub since: Option<Timestamp>,
|
||||
}
|
||||
|
||||
impl Window {
|
||||
/// The newest wraps, with no older bound.
|
||||
pub fn newest() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// The wraps strictly older than `oldest`.
|
||||
pub fn older_than(oldest: Timestamp) -> Self {
|
||||
Self {
|
||||
until: Some(oldest - 1u64),
|
||||
since: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The region between `since` and `oldest`, both inclusive.
|
||||
pub fn between(since: Timestamp, oldest: Timestamp) -> Self {
|
||||
Self {
|
||||
until: Some(oldest - 1u64),
|
||||
since: Some(since),
|
||||
}
|
||||
}
|
||||
|
||||
/// The window a channel is opened with.
|
||||
pub fn opening(cursor: ChannelCursor) -> Self {
|
||||
match cursor.newest {
|
||||
Some(newest) => Self {
|
||||
since: Some(newest - CURSOR_OVERLAP),
|
||||
until: None,
|
||||
},
|
||||
None => Self::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a relay said about one page subscription.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Settled {
|
||||
Replayed,
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
/// One relay's verdict on one page subscription.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PageReport {
|
||||
pub relay: RelayUrl,
|
||||
pub outcome: Settled,
|
||||
}
|
||||
|
||||
/// The page subscriptions in flight, by subscription id.
|
||||
pub struct PageRegistry {
|
||||
pages: Arc<Mutex<HashMap<SubscriptionId, flume::Sender<PageReport>>>>,
|
||||
}
|
||||
|
||||
impl Default for PageRegistry {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pages: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for PageRegistry {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
pages: Arc::clone(&self.pages),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PageRegistry {
|
||||
pub fn register(&self, id: SubscriptionId, sender: flume::Sender<PageReport>) {
|
||||
self.lock().insert(id, sender);
|
||||
}
|
||||
|
||||
pub fn unregister(&self, id: &SubscriptionId) {
|
||||
self.lock().remove(id);
|
||||
}
|
||||
|
||||
/// Forget every page still in flight, because its round is gone.
|
||||
pub fn clear(&self) {
|
||||
self.lock().clear();
|
||||
}
|
||||
|
||||
/// Hand a relay's verdict to the page that owns `id`, when it is still waiting.
|
||||
pub fn deliver(&self, id: &SubscriptionId, relay: RelayUrl, outcome: Settled) {
|
||||
let sender = self.lock().get(id).cloned();
|
||||
|
||||
let Some(sender) = sender else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(error) = sender.try_send(PageReport { relay, outcome }) {
|
||||
log::debug!("community: a page report was not delivered: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
fn lock(&self) -> MutexGuard<'_, HashMap<SubscriptionId, flume::Sender<PageReport>>> {
|
||||
match self.pages.lock() {
|
||||
Ok(pages) => pages,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What one paged fetch saw.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct WrapPage {
|
||||
pub opened: Vec<ChatRumor>,
|
||||
pub raw: usize,
|
||||
/// Wraps that reached us under a held plane but that no held key could open.
|
||||
pub unreadable: usize,
|
||||
pub newest: Option<Timestamp>,
|
||||
pub oldest: Option<Timestamp>,
|
||||
pub exhausted: bool,
|
||||
pub failed: bool,
|
||||
pub errors: usize,
|
||||
}
|
||||
|
||||
/// Walks a channel's history back over the community's own relays.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn page(
|
||||
client: &Client,
|
||||
pages: &PageRegistry,
|
||||
channel: &ChannelId,
|
||||
held: &[HeldKey],
|
||||
relays: &[RelayUrl],
|
||||
window: Window,
|
||||
max_pages: usize,
|
||||
limit: usize,
|
||||
) -> Result<WrapPage> {
|
||||
let planes: Vec<(HeldKey, GroupKey)> = held
|
||||
.iter()
|
||||
.map(|key| Ok((*key, channel_group_key(&key.key, channel, key.epoch)?)))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let authors: Vec<PublicKey> = planes.iter().map(|(_, group)| group.pk()).collect();
|
||||
|
||||
if authors.is_empty() || relays.is_empty() || limit == 0 {
|
||||
return Ok(WrapPage {
|
||||
failed: true,
|
||||
..WrapPage::default()
|
||||
});
|
||||
}
|
||||
|
||||
// A REQ can only target a relay the pool already knows about.
|
||||
connect_relays(client, relays).await;
|
||||
|
||||
let mut walk = Walk::new(relays, window);
|
||||
let mut opened = Vec::new();
|
||||
|
||||
for _ in 0..max_pages {
|
||||
if walk.is_done() {
|
||||
break;
|
||||
}
|
||||
|
||||
let filter = wrap_filter(&authors, walk.region(), limit);
|
||||
let asked: Vec<(usize, RelayUrl)> = walk
|
||||
.live()
|
||||
.map(|index| (index, walk.url(index).clone()))
|
||||
.collect();
|
||||
|
||||
let answers = ask_page(client, pages, &asked, &filter).await;
|
||||
|
||||
for (index, url) in &asked {
|
||||
match answers.get(url) {
|
||||
Some(Settled::Replayed) => {}
|
||||
Some(Settled::Refused(reason)) => {
|
||||
log::warn!("community: relay {url} refused a history page: {reason}");
|
||||
walk.reject(*index);
|
||||
}
|
||||
None => {
|
||||
log::warn!("community: relay {url} did not finish a history page");
|
||||
walk.reject(*index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let answered = client.database().query(filter).await?;
|
||||
|
||||
for wrap in walk.accept(answered, limit) {
|
||||
let Some((held, group)) = planes.iter().find(|(_, group)| group.pk() == wrap.pubkey)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Ok((stream, rumor)) = read_under(&wrap, held, group, channel) else {
|
||||
walk.unreadable += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
if cache_rumor(client, channel, &stream).await? {
|
||||
opened.push(rumor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(walk.finish(opened))
|
||||
}
|
||||
|
||||
/// Opens one wrap under a held key.
|
||||
fn read_under(
|
||||
wrap: &Event,
|
||||
held: &HeldKey,
|
||||
group: &GroupKey,
|
||||
channel: &ChannelId,
|
||||
) -> Result<(OpenedStream, ChatRumor)> {
|
||||
if held
|
||||
.retired_at
|
||||
.is_some_and(|retired| wrap.created_at > retired)
|
||||
{
|
||||
bail!("sealed after the key that reads it was retired");
|
||||
}
|
||||
|
||||
Ok(cord03::open(wrap, group, channel, held.epoch)?)
|
||||
}
|
||||
|
||||
/// The one filter a page is asked for.
|
||||
fn wrap_filter(authors: &[PublicKey], window: Window, limit: usize) -> Filter {
|
||||
let mut filter = Filter::new()
|
||||
.kinds([Kind::GiftWrap, Kind::Custom(KIND_WRAP_EPHEMERAL)])
|
||||
.authors(authors.iter().copied())
|
||||
.limit(limit);
|
||||
|
||||
if let Some(until) = window.until {
|
||||
filter = filter.until(until);
|
||||
}
|
||||
|
||||
if let Some(since) = window.since {
|
||||
filter = filter.since(since);
|
||||
}
|
||||
|
||||
filter
|
||||
}
|
||||
|
||||
/// One page's verdicts, by relay.
|
||||
type PageAnswers = BTreeMap<RelayUrl, Settled>;
|
||||
|
||||
async fn ask_page(
|
||||
client: &Client,
|
||||
pages: &PageRegistry,
|
||||
asked: &[(usize, RelayUrl)],
|
||||
filter: &Filter,
|
||||
) -> PageAnswers {
|
||||
let mut answers = PageAnswers::new();
|
||||
let distinct: BTreeSet<RelayUrl> = asked.iter().map(|(_, url)| url.clone()).collect();
|
||||
let mut targets: Vec<(RelayUrl, Vec<Filter>)> = Vec::with_capacity(distinct.len());
|
||||
|
||||
for url in &distinct {
|
||||
match client.relay(url).await {
|
||||
Ok(Some(_)) => targets.push((url.clone(), vec![filter.clone()])),
|
||||
Ok(None) => {
|
||||
log::warn!("community: relay {url} is not in the pool for a history page");
|
||||
answers.insert(
|
||||
url.clone(),
|
||||
Settled::Refused("not in the relay pool".to_owned()),
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!("community: relay {url} could not be looked up: {error}");
|
||||
answers.insert(url.clone(), Settled::Refused(error.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if targets.is_empty() {
|
||||
return answers;
|
||||
}
|
||||
|
||||
let id = history_subscription();
|
||||
let (sender, receiver) = flume::bounded(distinct.len());
|
||||
pages.register(id.clone(), sender);
|
||||
|
||||
let options = SubscribeAutoCloseOptions::default()
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||
.timeout(Some(PAGE_TIMEOUT));
|
||||
|
||||
match client
|
||||
.subscribe(ReqTarget::manual(targets))
|
||||
.with_id(id.clone())
|
||||
.close_on(options)
|
||||
.await
|
||||
{
|
||||
Ok(output) => {
|
||||
for (url, reason) in output.failed {
|
||||
answers.insert(url, Settled::Refused(reason));
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + PAGE_TIMEOUT;
|
||||
|
||||
while answers.len() < distinct.len() {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
|
||||
let Some(report) = within(remaining, receiver.recv_async()).await else {
|
||||
break;
|
||||
};
|
||||
|
||||
match report {
|
||||
Ok(report) => {
|
||||
answers.insert(report.relay, report.outcome);
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!("community: a history page's reports were lost: {error}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!("community: a history page REQ was refused: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
pages.unregister(&id);
|
||||
|
||||
answers
|
||||
}
|
||||
|
||||
pub(crate) fn auth_required(reason: &str) -> bool {
|
||||
matches!(
|
||||
MachineReadablePrefix::parse(reason),
|
||||
Some(MachineReadablePrefix::AuthRequired)
|
||||
)
|
||||
}
|
||||
|
||||
fn history_subscription() -> SubscriptionId {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
SubscriptionId::new(format!("history-{}", NEXT.fetch_add(1, Ordering::Relaxed)))
|
||||
}
|
||||
|
||||
async fn within<F>(limit: Duration, future: F) -> Option<F::Output>
|
||||
where
|
||||
F: Future,
|
||||
{
|
||||
let future = std::pin::pin!(future);
|
||||
let deadline = std::pin::pin!(smol::Timer::after(limit));
|
||||
|
||||
match select(future, deadline).await {
|
||||
Either::Left((output, _)) => Some(output),
|
||||
Either::Right(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// One pass over a channel's history, page by page.
|
||||
#[derive(Debug)]
|
||||
struct Walk {
|
||||
relays: Vec<Walker>,
|
||||
since: Option<Timestamp>,
|
||||
/// The inclusive upper bound of the next page.
|
||||
cursor: Option<Timestamp>,
|
||||
seen: BTreeSet<EventId>,
|
||||
newest: Option<Timestamp>,
|
||||
oldest: Option<Timestamp>,
|
||||
raw: usize,
|
||||
errors: usize,
|
||||
/// Wraps the caller could not read under any held key.
|
||||
unreadable: usize,
|
||||
/// A short page ended the walk.
|
||||
bottom: bool,
|
||||
}
|
||||
|
||||
/// One relay's standing in a walk.
|
||||
#[derive(Debug)]
|
||||
struct Walker {
|
||||
url: RelayUrl,
|
||||
dead: bool,
|
||||
}
|
||||
|
||||
impl Walk {
|
||||
fn new(relays: &[RelayUrl], window: Window) -> Self {
|
||||
Self {
|
||||
relays: relays
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|url| Walker { url, dead: false })
|
||||
.collect(),
|
||||
since: window.since,
|
||||
cursor: window.until,
|
||||
seen: BTreeSet::new(),
|
||||
newest: None,
|
||||
oldest: None,
|
||||
raw: 0,
|
||||
errors: 0,
|
||||
unreadable: 0,
|
||||
bottom: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_done(&self) -> bool {
|
||||
self.bottom || self.relays.iter().all(|walker| walker.dead)
|
||||
}
|
||||
|
||||
/// The region the next page asks for.
|
||||
fn region(&self) -> Window {
|
||||
Window {
|
||||
until: self.cursor,
|
||||
since: self.since,
|
||||
}
|
||||
}
|
||||
|
||||
fn live(&self) -> impl Iterator<Item = usize> + '_ {
|
||||
(0..self.relays.len()).filter(|&index| !self.relays[index].dead)
|
||||
}
|
||||
|
||||
fn url(&self, index: usize) -> &RelayUrl {
|
||||
&self.relays[index].url
|
||||
}
|
||||
|
||||
fn reject(&mut self, index: usize) {
|
||||
self.relays[index].dead = true;
|
||||
self.errors += 1;
|
||||
}
|
||||
|
||||
fn accept(&mut self, page: BTreeSet<Event>, limit: usize) -> Vec<Event> {
|
||||
if page.len() < limit {
|
||||
self.bottom = true;
|
||||
}
|
||||
|
||||
let mut oldest: Option<Timestamp> = None;
|
||||
let mut events = Vec::with_capacity(page.len());
|
||||
|
||||
for event in page {
|
||||
let at = event.created_at;
|
||||
self.newest = Some(self.newest.map_or(at, |newest| newest.max(at)));
|
||||
oldest = Some(oldest.map_or(at, |oldest| oldest.min(at)));
|
||||
|
||||
if self.seen.insert(event.id) {
|
||||
self.raw += 1;
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
// The walk's floor, which the round resumes the older pass from.
|
||||
if let Some(oldest) = oldest {
|
||||
self.oldest = Some(self.oldest.map_or(oldest, |held| held.min(oldest)));
|
||||
}
|
||||
|
||||
match oldest {
|
||||
Some(oldest) if !oldest.is_zero() => self.cursor = Some(oldest - 1u64),
|
||||
Some(_) => self.bottom = true,
|
||||
None => {}
|
||||
}
|
||||
|
||||
events
|
||||
}
|
||||
|
||||
fn finish(self, opened: Vec<ChatRumor>) -> WrapPage {
|
||||
let swept = self.bottom && self.errors == 0;
|
||||
|
||||
WrapPage {
|
||||
opened,
|
||||
raw: self.raw,
|
||||
unreadable: self.unreadable,
|
||||
newest: self.newest,
|
||||
oldest: self.oldest,
|
||||
exhausted: swept && self.raw > 0,
|
||||
failed: self.errors > 0 || (self.bottom && self.raw == 0),
|
||||
errors: self.errors,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::cmp::Reverse;
|
||||
|
||||
use concord::Epoch;
|
||||
use concord::cord03::{build_message, seal_rumor};
|
||||
use concord::derive::channel_group_key;
|
||||
|
||||
use super::*;
|
||||
|
||||
const SECRET: [u8; 32] = [0x07u8; 32];
|
||||
const NEXT_SECRET: [u8; 32] = [0x11u8; 32];
|
||||
|
||||
fn serve_page(database: &BTreeSet<Event>, window: Window, limit: usize) -> BTreeSet<Event> {
|
||||
let mut events: Vec<Event> = database
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
window.until.is_none_or(|until| event.created_at <= until)
|
||||
&& window.since.is_none_or(|since| event.created_at >= since)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
events.sort_by_key(|event| Reverse(event.created_at));
|
||||
events.truncate(limit);
|
||||
events.into_iter().collect()
|
||||
}
|
||||
|
||||
fn relay_url(host: &str) -> RelayUrl {
|
||||
RelayUrl::parse(&format!("wss://{host}.example.com")).expect("parses")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_walk_pages_back_across_a_rekey() {
|
||||
let channel = ChannelId::from_bytes([0x9cu8; 32]);
|
||||
let author = Keys::generate();
|
||||
let held = [
|
||||
HeldKey {
|
||||
epoch: Epoch(0),
|
||||
key: SECRET,
|
||||
retired_at: None,
|
||||
},
|
||||
HeldKey {
|
||||
epoch: Epoch(1),
|
||||
key: NEXT_SECRET,
|
||||
retired_at: None,
|
||||
},
|
||||
];
|
||||
let planes: Vec<(HeldKey, GroupKey)> = held
|
||||
.iter()
|
||||
.map(|key| {
|
||||
(
|
||||
*key,
|
||||
channel_group_key(&key.key, &channel, key.epoch).expect("derives"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Three messages a second apart: a page boundary falls between each.
|
||||
let base = 1_700_000_000_000;
|
||||
let mut relay: BTreeSet<Event> = BTreeSet::new();
|
||||
|
||||
for (content, secret, epoch, at_ms) in [
|
||||
("before the rekey", &SECRET, Epoch(0), base),
|
||||
("still before", &SECRET, Epoch(0), base + 1_000),
|
||||
("after the rekey", &NEXT_SECRET, Epoch(1), base + 2_000),
|
||||
] {
|
||||
let group = channel_group_key(secret, &channel, epoch).expect("derives");
|
||||
let rumor = build_message(
|
||||
author.public_key(),
|
||||
&channel,
|
||||
epoch,
|
||||
content,
|
||||
None,
|
||||
at_ms,
|
||||
None,
|
||||
);
|
||||
relay.insert(
|
||||
smol::block_on(seal_rumor(&rumor, &group, &author, false))
|
||||
.expect("seals")
|
||||
.0,
|
||||
);
|
||||
}
|
||||
|
||||
let mut walk = Walk::new(&[relay_url("history")], Window::newest());
|
||||
let mut found = Vec::new();
|
||||
let mut pages = 0;
|
||||
|
||||
while !walk.is_done() && pages < 10 {
|
||||
pages += 1;
|
||||
let page = serve_page(&relay, walk.region(), 2);
|
||||
|
||||
for wrap in walk.accept(page, 2) {
|
||||
let Some((held, group)) =
|
||||
planes.iter().find(|(_, group)| group.pk() == wrap.pubkey)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let (_, rumor) = cord03::open(&wrap, group, &channel, held.epoch).expect("opens");
|
||||
found.push(rumor);
|
||||
}
|
||||
}
|
||||
|
||||
found.sort_by_key(|rumor| (Reverse(rumor.at_ms), rumor.id));
|
||||
|
||||
let contents: Vec<&str> = found.iter().map(|rumor| rumor.content.as_str()).collect();
|
||||
assert_eq!(
|
||||
contents,
|
||||
["after the rekey", "still before", "before the rekey"]
|
||||
);
|
||||
|
||||
let page = walk.finish(Vec::new());
|
||||
assert!(page.exhausted);
|
||||
assert!(!page.failed);
|
||||
assert_eq!(page.raw, 3);
|
||||
assert_eq!(page.newest, Some(Timestamp::from_secs(1_700_000_002)));
|
||||
assert_eq!(
|
||||
page.oldest,
|
||||
Some(Timestamp::from_secs(1_700_000_000)),
|
||||
"the walk reports its floor, which the older pass resumes below"
|
||||
);
|
||||
}
|
||||
|
||||
/// A wrap that reaches us and still will not open is history we cannot read,
|
||||
/// not history that does not exist.
|
||||
#[test]
|
||||
fn a_wrap_no_held_key_can_open_reads_as_unreadable() {
|
||||
let channel = ChannelId::from_bytes([0x9cu8; 32]);
|
||||
let other = ChannelId::from_bytes([0x9du8; 32]);
|
||||
let author = Keys::generate();
|
||||
let group = channel_group_key(&SECRET, &channel, Epoch(0)).expect("derives");
|
||||
let held = HeldKey {
|
||||
epoch: Epoch(0),
|
||||
key: SECRET,
|
||||
retired_at: Some(Timestamp::from_secs(1_000)),
|
||||
};
|
||||
|
||||
let wrap_at = |channel: &ChannelId, at_ms: u64| {
|
||||
let rumor = build_message(
|
||||
author.public_key(),
|
||||
channel,
|
||||
Epoch(0),
|
||||
"sealed",
|
||||
None,
|
||||
at_ms,
|
||||
None,
|
||||
);
|
||||
smol::block_on(seal_rumor(&rumor, &group, &author, false))
|
||||
.expect("seals")
|
||||
.0
|
||||
};
|
||||
|
||||
// Sealed before the rotation superseded this key, so it still reads.
|
||||
let before = wrap_at(&channel, 999_000);
|
||||
assert!(read_under(&before, &held, &group, &channel).is_ok());
|
||||
|
||||
// Sealed after the cutoff the rotation set on that key.
|
||||
let after = wrap_at(&channel, 1_001_000);
|
||||
assert!(read_under(&after, &held, &group, &channel).is_err());
|
||||
|
||||
// Sealed to this plane but bound to another channel.
|
||||
let misbound = wrap_at(&other, 999_000);
|
||||
assert!(read_under(&misbound, &held, &group, &channel).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_answer_never_seals_the_channel() {
|
||||
let database: BTreeSet<Event> = BTreeSet::new();
|
||||
let mut walk = Walk::new(&[relay_url("history")], Window::newest());
|
||||
|
||||
let page = serve_page(&database, walk.region(), 50);
|
||||
assert!(walk.accept(page, 50).is_empty());
|
||||
|
||||
let page = walk.finish(Vec::new());
|
||||
assert!(page.failed);
|
||||
assert!(!page.exhausted);
|
||||
assert_eq!(page.raw, 0);
|
||||
assert_eq!(page.oldest, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_silent_relay_blocks_the_bottom() {
|
||||
let database: BTreeSet<Event> = BTreeSet::new();
|
||||
let mut walk = Walk::new(
|
||||
&[relay_url("history"), relay_url("archive")],
|
||||
Window::newest(),
|
||||
);
|
||||
|
||||
// One relay answered the empty page; the other never answered at all, so
|
||||
// its share of the region was never read and the walk must not seal.
|
||||
walk.reject(1);
|
||||
let page = serve_page(&database, walk.region(), 50);
|
||||
assert!(walk.accept(page, 50).is_empty());
|
||||
|
||||
let page = walk.finish(Vec::new());
|
||||
assert!(page.failed);
|
||||
assert!(!page.exhausted);
|
||||
assert_eq!(page.errors, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_boundary_is_exclusive() {
|
||||
let database = BTreeSet::from([
|
||||
event_at(Timestamp::from_secs(1_700_000_000)),
|
||||
event_at(Timestamp::from_secs(1_700_000_001)),
|
||||
]);
|
||||
let mut walk = Walk::new(&[relay_url("history")], Window::newest());
|
||||
|
||||
let first = walk.accept(serve_page(&database, walk.region(), 1), 1);
|
||||
let second = walk.accept(serve_page(&database, walk.region(), 1), 1);
|
||||
|
||||
assert_eq!(first.len(), 1);
|
||||
assert_eq!(second.len(), 1);
|
||||
assert_ne!(first[0].id, second[0].id);
|
||||
|
||||
let oldest = second
|
||||
.iter()
|
||||
.map(|event| event.created_at)
|
||||
.min()
|
||||
.expect("one wrap");
|
||||
assert_eq!(oldest, Timestamp::from_secs(1_700_000_000));
|
||||
}
|
||||
|
||||
fn event_at(at: Timestamp) -> Event {
|
||||
let keys = Keys::generate();
|
||||
EventBuilder::new(Kind::TextNote, "page")
|
||||
.custom_created_at(at)
|
||||
.finalize(&keys)
|
||||
.expect("signs")
|
||||
}
|
||||
|
||||
/// A cold channel asks wide; a warm one resumes at the overlap above its
|
||||
/// cursor, and `older_than` never includes the boundary event itself.
|
||||
#[test]
|
||||
fn a_cold_window_is_open_and_a_warm_one_resumes_at_the_overlap() {
|
||||
assert_eq!(Window::opening(ChannelCursor::default()), Window::default());
|
||||
|
||||
let warm = Window::opening(ChannelCursor {
|
||||
newest: Some(Timestamp::from_secs(2_000_000)),
|
||||
oldest: Some(Timestamp::from_secs(1_000)),
|
||||
exhausted: false,
|
||||
});
|
||||
assert_eq!(
|
||||
warm,
|
||||
Window {
|
||||
since: Some(Timestamp::from_secs(2_000_000) - CURSOR_OVERLAP),
|
||||
until: None,
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
Window::older_than(Timestamp::from_secs(1_000)),
|
||||
Window {
|
||||
since: None,
|
||||
until: Some(Timestamp::from_secs(999)),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// A page whose round has moved on is simply a report nobody reads.
|
||||
#[test]
|
||||
fn a_page_that_moved_on_receives_nothing() {
|
||||
let pages = PageRegistry::default();
|
||||
let id = SubscriptionId::new("concord-history-9");
|
||||
let (sender, receiver) = flume::bounded(1);
|
||||
|
||||
pages.register(id.clone(), sender);
|
||||
pages.unregister(&id);
|
||||
pages.deliver(&id, relay_url("history"), Settled::Replayed);
|
||||
|
||||
assert!(receiver.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,829 +0,0 @@
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
pub use concord::cord02::CommunityMetadata;
|
||||
pub use concord::cord03::{ChatMessage, ReplyRef};
|
||||
use concord::state::CommunityState;
|
||||
pub use concord::{ChannelId, CommunityId, Epoch};
|
||||
use futures::future::{Either, select};
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::NostrRegistry;
|
||||
|
||||
use crate::history::{PageRegistry, Settled, auth_required};
|
||||
use crate::rekey::WatchRegistry;
|
||||
|
||||
pub mod cache;
|
||||
mod community;
|
||||
pub mod history;
|
||||
mod rekey;
|
||||
mod sync;
|
||||
|
||||
pub use community::*;
|
||||
pub use sync::*;
|
||||
|
||||
/// How long a burst of relay notifications is collected before it is folded.
|
||||
const PUMP_WINDOW: Duration = Duration::from_millis(200);
|
||||
/// How long a community's relays may deliver nothing before
|
||||
/// its standing subscription is torn down and re-issued.
|
||||
const LIVE_ROTATE: Duration = Duration::from_secs(90);
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
CommunityRegistry::set_global(cx.new(CommunityRegistry::new), cx);
|
||||
}
|
||||
|
||||
struct GlobalCommunityRegistry(Entity<CommunityRegistry>);
|
||||
|
||||
impl Global for GlobalCommunityRegistry {}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Signal {
|
||||
Event(CommunityId),
|
||||
List,
|
||||
Rekey(CommunityId),
|
||||
}
|
||||
|
||||
/// Which standing subscription an id belongs to.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Route {
|
||||
List,
|
||||
Community(CommunityId),
|
||||
}
|
||||
|
||||
fn route_of(id: &SubscriptionId) -> Option<Route> {
|
||||
if sync::is_list_subscription(id) {
|
||||
return Some(Route::List);
|
||||
}
|
||||
|
||||
sync::community_of(id).map(Route::Community)
|
||||
}
|
||||
|
||||
/// What a window of relay notifications saw, waiting to be folded once.
|
||||
#[derive(Default)]
|
||||
struct Batch {
|
||||
list: bool,
|
||||
communities: BTreeSet<CommunityId>,
|
||||
rekeys: BTreeSet<CommunityId>,
|
||||
}
|
||||
|
||||
/// Whether the pump should keep listening.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Flow {
|
||||
Continue,
|
||||
Stop,
|
||||
}
|
||||
|
||||
/// Fold one notification into the window, or settle a page it belongs to.
|
||||
fn route(
|
||||
notification: ClientNotification,
|
||||
pages: &PageRegistry,
|
||||
watches: &WatchRegistry,
|
||||
batch: &mut Batch,
|
||||
) -> Flow {
|
||||
match notification {
|
||||
ClientNotification::Event {
|
||||
subscription_id, ..
|
||||
} => match route_of(&subscription_id) {
|
||||
Some(Route::List) => batch.list = true,
|
||||
Some(Route::Community(id)) => {
|
||||
batch.communities.insert(id);
|
||||
}
|
||||
None => {
|
||||
if let Some(id) = watches.community_of(&subscription_id) {
|
||||
batch.rekeys.insert(id);
|
||||
}
|
||||
}
|
||||
},
|
||||
ClientNotification::Message { relay_url, message } => match *message {
|
||||
RelayMessage::EndOfStoredEvents(id) => {
|
||||
pages.deliver(&id, relay_url, Settled::Replayed);
|
||||
}
|
||||
RelayMessage::Closed {
|
||||
subscription_id,
|
||||
message,
|
||||
} if !auth_required(&message) => {
|
||||
pages.deliver(
|
||||
&subscription_id,
|
||||
relay_url,
|
||||
Settled::Refused(message.into_owned()),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
ClientNotification::Shutdown => return Flow::Stop,
|
||||
}
|
||||
|
||||
Flow::Continue
|
||||
}
|
||||
|
||||
/// Hand the window's signals to the foreground consumer, one per community.
|
||||
async fn flush(tx: &flume::Sender<Signal>, batch: &mut Batch) -> Result<()> {
|
||||
for id in std::mem::take(&mut batch.communities) {
|
||||
tx.send_async(Signal::Event(id)).await?;
|
||||
}
|
||||
|
||||
for id in std::mem::take(&mut batch.rekeys) {
|
||||
tx.send_async(Signal::Rekey(id)).await?;
|
||||
}
|
||||
|
||||
if std::mem::take(&mut batch.list) {
|
||||
tx.send_async(Signal::List).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl EventEmitter<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>,
|
||||
/// When a relay last delivered something for a community,
|
||||
/// which is the only evidence the standing subscription is alive.
|
||||
last_event: HashMap<CommunityId, Instant>,
|
||||
/// One observer per tracked community, dropped on reset
|
||||
observers: HashMap<CommunityId, Subscription>,
|
||||
signal_tx: flume::Sender<Signal>,
|
||||
signal_rx: flume::Receiver<Signal>,
|
||||
/// The page subscriptions in flight, shared with the notification pump.
|
||||
pages: PageRegistry,
|
||||
/// The rekey watch subscriptions, resolved to their community.
|
||||
watches: WatchRegistry,
|
||||
tasks: SmallVec<[Task<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<()>>>,
|
||||
/// The round scheduler (cancelled on signer change)
|
||||
scheduler: 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(),
|
||||
last_event: HashMap::new(),
|
||||
observers: HashMap::new(),
|
||||
signal_tx: tx,
|
||||
signal_rx: rx,
|
||||
pages: PageRegistry::default(),
|
||||
watches: WatchRegistry::default(),
|
||||
tasks: smallvec![],
|
||||
notification_listener: None,
|
||||
signal_consumer: None,
|
||||
scheduler: None,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn communities(&self) -> &[Entity<Community>] {
|
||||
&self.communities
|
||||
}
|
||||
|
||||
pub fn community(&self, id: &CommunityId) -> Option<Entity<Community>> {
|
||||
self.index.get(id).cloned()
|
||||
}
|
||||
|
||||
/// Create a community owned by the current account and begin tracking it.
|
||||
pub fn create(&mut self, metadata: CommunityMetadata, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let current_user = nostr.read(cx).current_user();
|
||||
|
||||
if current_user.is_none() {
|
||||
cx.emit(CommunityEvent::Error(
|
||||
"cannot create a community without an account".to_owned(),
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
let signer = nostr.read(cx).signer();
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let task =
|
||||
cx.background_spawn(async move { sync::create(&client, &signer, &metadata).await });
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(_state) => this.update(cx, |this, cx| this.load(cx))?,
|
||||
Err(error) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(CommunityEvent::Error(error.to_string()));
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Forget the current account and cancel everything in flight.
|
||||
pub fn reset(&mut self, cx: &mut Context<Self>) {
|
||||
self.notification_listener = None;
|
||||
self.signal_consumer = None;
|
||||
self.scheduler = None;
|
||||
self.tasks.clear();
|
||||
self.observers.clear();
|
||||
self.pages.clear();
|
||||
self.watches.clear();
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let ids: Vec<CommunityId> = self.index.keys().copied().collect();
|
||||
|
||||
for id in ids {
|
||||
let client = client.clone();
|
||||
let subscription = sync::subscription_id(&id);
|
||||
let rekey = rekey::subscription_id(&id);
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
client.unsubscribe(&subscription).await?;
|
||||
client.unsubscribe(&rekey).await?;
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
self.communities.clear();
|
||||
self.index.clear();
|
||||
self.synced.clear();
|
||||
self.last_event.clear();
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Subscribe to the account's community list.
|
||||
fn subscribe_list(&mut self, cx: &mut Context<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.
|
||||
///
|
||||
/// A community that survives the reload keeps its entity, so an open panel
|
||||
/// and a browsing sidebar stay pointed at a live community.
|
||||
fn track(&mut self, states: Vec<CommunityState>, cx: &mut Context<Self>) {
|
||||
let mut communities = Vec::with_capacity(states.len());
|
||||
|
||||
for state in states {
|
||||
let id = state.id;
|
||||
|
||||
let community = match self.index.remove(&id) {
|
||||
Some(community) => {
|
||||
// The list can carry plane material the store does not.
|
||||
if community.read(cx).state() != &state {
|
||||
community.update(cx, |community, _cx| community.adopt(state));
|
||||
}
|
||||
|
||||
community
|
||||
}
|
||||
None => {
|
||||
let community = cx.new(|_| Community::new(state, self.pages.clone()));
|
||||
|
||||
self.observers.insert(
|
||||
id,
|
||||
cx.observe(&community, |this, _community, cx| {
|
||||
this.sync_subscriptions(cx);
|
||||
cx.notify();
|
||||
}),
|
||||
);
|
||||
|
||||
community
|
||||
}
|
||||
};
|
||||
|
||||
communities.push((id, community));
|
||||
}
|
||||
|
||||
// Whatever the index still holds is no longer in the list.
|
||||
let dropped: Vec<CommunityId> = self.index.keys().copied().collect();
|
||||
|
||||
for id in dropped {
|
||||
self.observers.remove(&id);
|
||||
self.synced.remove(&id);
|
||||
self.last_event.remove(&id);
|
||||
}
|
||||
|
||||
self.communities = communities
|
||||
.iter()
|
||||
.map(|(_, community)| community.clone())
|
||||
.collect();
|
||||
self.index = communities.into_iter().collect();
|
||||
|
||||
self.sync_subscriptions(cx);
|
||||
|
||||
// A backlog already in the database produces no notification, so fold it once.
|
||||
for community in self.communities.clone() {
|
||||
community.update(cx, |community, cx| community.refresh(cx));
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn refresh(&mut self, id: CommunityId, cx: &mut Context<Self>) {
|
||||
let Some(community) = self.index.get(&id).cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
community.update(cx, |community, cx| community.refresh(cx));
|
||||
}
|
||||
|
||||
/// Adopt whatever a community's rekey watch has delivered.
|
||||
fn rekey(&mut self, id: CommunityId, cx: &mut Context<Self>) {
|
||||
let Some(community) = self.index.get(&id).cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
community.update(cx, |community, cx| community.rekey(cx));
|
||||
}
|
||||
|
||||
/// One scheduler pass: every community repairs itself if it has gone stale,
|
||||
/// and any whose relays have gone quiet is re-subscribed.
|
||||
fn tick(&mut self, cx: &mut Context<Self>) {
|
||||
for community in self.communities.clone() {
|
||||
community.update(cx, |community, cx| community.tick(cx));
|
||||
}
|
||||
self.rotate_quiet(cx);
|
||||
}
|
||||
|
||||
/// Re-issue the standing subscription of every community that has been quiet for `LIVE_ROTATE`
|
||||
fn rotate_quiet(&mut self, cx: &mut Context<Self>) {
|
||||
let now = Instant::now();
|
||||
let ids: Vec<CommunityId> = self.index.keys().copied().collect();
|
||||
let mut rotated = false;
|
||||
|
||||
for id in ids {
|
||||
let quiet = self
|
||||
.last_event
|
||||
.get(&id)
|
||||
.is_none_or(|at| now.duration_since(*at) >= LIVE_ROTATE);
|
||||
|
||||
if !quiet {
|
||||
continue;
|
||||
}
|
||||
|
||||
self.synced.remove(&id);
|
||||
rotated = true;
|
||||
}
|
||||
|
||||
if rotated {
|
||||
self.sync_subscriptions(cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-subscribe every community whose held planes moved.
|
||||
fn sync_subscriptions(&mut self, cx: &mut Context<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::live_filter(&planes, sync::live_window(&state, Timestamp::now()));
|
||||
let relays = key.relays().to_vec();
|
||||
|
||||
// A fresh REQ counts as evidence of life for this community.
|
||||
self.last_event.insert(id, Instant::now());
|
||||
|
||||
// The rekey watch is a second standing REQ over the same relays.
|
||||
let watch = match rekey::watches(&state) {
|
||||
Ok(watches) => rekey::watch_filter(&watches),
|
||||
Err(error) => {
|
||||
cx.emit(CommunityEvent::Error(error.to_string()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let rekey_subscription = rekey::subscription_id(&id);
|
||||
self.watches.register(rekey_subscription.clone(), id);
|
||||
|
||||
self.synced.insert(id, key);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if let Err(error) = subscribe(&client, &subscription, &relays, filter).await {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(CommunityEvent::Error(error.to_string()));
|
||||
})?;
|
||||
}
|
||||
|
||||
if let Err(error) = subscribe(&client, &rekey_subscription, &relays, watch).await {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(CommunityEvent::Error(error.to_string()));
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
|
||||
self.notification_listener = None;
|
||||
self.signal_consumer = None;
|
||||
self.scheduler = None;
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let tx = self.signal_tx.clone();
|
||||
let rx = self.signal_rx.clone();
|
||||
let pages = self.pages.clone();
|
||||
let watches = self.watches.clone();
|
||||
let executor = cx.background_executor().clone();
|
||||
|
||||
self.notification_listener = Some(cx.background_spawn(async move {
|
||||
let mut notifications = client.notifications();
|
||||
let mut batch = Batch::default();
|
||||
|
||||
'outer: loop {
|
||||
match notifications.next().await {
|
||||
Some(notification) => {
|
||||
if route(notification, &pages, &watches, &mut batch) == Flow::Stop {
|
||||
flush(&tx, &mut batch).await?;
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
None => break 'outer,
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + PUMP_WINDOW;
|
||||
|
||||
loop {
|
||||
let now = Instant::now();
|
||||
|
||||
if now >= deadline {
|
||||
break;
|
||||
}
|
||||
|
||||
let timer = executor.timer(deadline - now);
|
||||
let next = notifications.next();
|
||||
futures::pin_mut!(timer);
|
||||
futures::pin_mut!(next);
|
||||
|
||||
match select(next, timer).await {
|
||||
Either::Left((Some(notification), _)) => {
|
||||
if route(notification, &pages, &watches, &mut batch) == Flow::Stop {
|
||||
flush(&tx, &mut batch).await?;
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
Either::Left((None, _)) => break 'outer,
|
||||
Either::Right(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
flush(&tx, &mut batch).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
|
||||
self.signal_consumer = Some(cx.spawn(async move |this, cx| {
|
||||
while let Ok(signal) = rx.recv_async().await {
|
||||
match signal {
|
||||
Signal::Event(id) => this.update(cx, |this, cx| {
|
||||
// The only proof a community's subscription is still
|
||||
// delivering anything.
|
||||
this.last_event.insert(id, Instant::now());
|
||||
this.refresh(id, cx);
|
||||
})?,
|
||||
Signal::Rekey(id) => this.update(cx, |this, cx| this.rekey(id, cx))?,
|
||||
Signal::List => this.update(cx, |this, cx| this.load(cx))?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
|
||||
self.scheduler = Some(cx.spawn(async move |this, cx| {
|
||||
loop {
|
||||
cx.background_executor().timer(MIN_ROUND_INTERVAL).await;
|
||||
|
||||
if let Some(registry) = this.upgrade() {
|
||||
registry.update(cx, |this, cx| {
|
||||
this.tick(cx);
|
||||
});
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async fn subscribe(
|
||||
client: &Client,
|
||||
id: &SubscriptionId,
|
||||
relays: &[RelayUrl],
|
||||
filter: Filter,
|
||||
) -> Result<()> {
|
||||
client.unsubscribe(id).await?;
|
||||
|
||||
if relays.is_empty() {
|
||||
log::warn!("community {id}: no relay to subscribe to");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut targets: Vec<(RelayUrl, Vec<Filter>)> = Vec::with_capacity(relays.len());
|
||||
|
||||
for url in relays {
|
||||
if let Err(error) = client.add_relay(url).and_connect().await {
|
||||
log::warn!("community {id}: failed to add relay {url}: {error}");
|
||||
}
|
||||
|
||||
match client.relay(url).await {
|
||||
Ok(Some(_)) => targets.push((url.clone(), vec![filter.clone()])),
|
||||
Ok(None) => log::warn!("community {id}: relay {url} is not in the pool"),
|
||||
Err(error) => log::warn!("community {id}: relay {url} could not be looked up: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
if targets.is_empty() {
|
||||
log::warn!("community {id}: no relay accepted the standing subscription");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let output = client
|
||||
.subscribe(ReqTarget::manual(targets))
|
||||
.with_id(id.clone())
|
||||
.await?;
|
||||
|
||||
if !output.failed.is_empty() {
|
||||
log::warn!(
|
||||
"community {id}: {} relay(s) rejected the subscription",
|
||||
output.failed.len()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn relay() -> RelayUrl {
|
||||
RelayUrl::parse("wss://relay.example").expect("a url")
|
||||
}
|
||||
|
||||
fn event(subscription_id: SubscriptionId) -> ClientNotification {
|
||||
let keys = Keys::generate();
|
||||
let event = EventBuilder::new(Kind::TextNote, "hi")
|
||||
.finalize(&keys)
|
||||
.expect("signs");
|
||||
|
||||
ClientNotification::Event {
|
||||
relay_url: relay(),
|
||||
subscription_id,
|
||||
event: Box::new(event),
|
||||
}
|
||||
}
|
||||
|
||||
fn message(message: RelayMessage<'static>) -> ClientNotification {
|
||||
ClientNotification::Message {
|
||||
relay_url: relay(),
|
||||
message: Box::new(message),
|
||||
}
|
||||
}
|
||||
|
||||
/// A burst within one window folds each community once, and the list once,
|
||||
/// however many events arrived.
|
||||
#[test]
|
||||
fn a_burst_collapses_to_one_signal_per_community() {
|
||||
let pages = PageRegistry::default();
|
||||
let watches = WatchRegistry::default();
|
||||
let community = CommunityId::from_bytes([0x42; 32]);
|
||||
let mut batch = Batch::default();
|
||||
|
||||
let plane = event(sync::subscription_id(&community));
|
||||
for _ in 0..50 {
|
||||
assert_eq!(
|
||||
route(plane.clone(), &pages, &watches, &mut batch),
|
||||
Flow::Continue
|
||||
);
|
||||
}
|
||||
|
||||
route(
|
||||
event(sync::list_subscription_id()),
|
||||
&pages,
|
||||
&watches,
|
||||
&mut batch,
|
||||
);
|
||||
|
||||
// A page's event is folded from the database later, not routed here.
|
||||
route(
|
||||
event(SubscriptionId::new("concord-history-7")),
|
||||
&pages,
|
||||
&watches,
|
||||
&mut batch,
|
||||
);
|
||||
|
||||
assert!(batch.list);
|
||||
assert_eq!(batch.communities, BTreeSet::from([community]));
|
||||
assert!(batch.rekeys.is_empty());
|
||||
}
|
||||
|
||||
/// A rekey watch's wraps put themselves in the database; the pump's only job
|
||||
/// is to wake the adoption pass, and it resolves the community by id.
|
||||
#[test]
|
||||
fn a_rekey_watch_event_wakes_its_community() {
|
||||
let pages = PageRegistry::default();
|
||||
let watches = WatchRegistry::default();
|
||||
let community = CommunityId::from_bytes([0x42; 32]);
|
||||
let id = rekey::subscription_id(&community);
|
||||
watches.register(id.clone(), community);
|
||||
|
||||
let mut batch = Batch::default();
|
||||
route(event(id), &pages, &watches, &mut batch);
|
||||
|
||||
assert_eq!(batch.rekeys, BTreeSet::from([community]));
|
||||
assert!(batch.communities.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_eose_settles_only_the_page_that_owns_the_id() {
|
||||
let pages = PageRegistry::default();
|
||||
let mine = SubscriptionId::new("concord-history-1");
|
||||
let other = SubscriptionId::new("concord-history-2");
|
||||
let (mine_tx, mine_rx) = flume::bounded(1);
|
||||
let (other_tx, other_rx) = flume::bounded(1);
|
||||
pages.register(mine.clone(), mine_tx);
|
||||
pages.register(other, other_tx);
|
||||
|
||||
let mut batch = Batch::default();
|
||||
let flow = route(
|
||||
message(RelayMessage::eose(mine)),
|
||||
&pages,
|
||||
&WatchRegistry::default(),
|
||||
&mut batch,
|
||||
);
|
||||
|
||||
assert_eq!(flow, Flow::Continue);
|
||||
assert!(matches!(
|
||||
mine_rx.try_recv().expect("a report").outcome,
|
||||
Settled::Replayed
|
||||
));
|
||||
assert!(other_rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_page_settles_its_relay_as_refused() {
|
||||
let pages = PageRegistry::default();
|
||||
let id = SubscriptionId::new("concord-history-1");
|
||||
let (sender, receiver) = flume::bounded(1);
|
||||
pages.register(id.clone(), sender);
|
||||
|
||||
let mut batch = Batch::default();
|
||||
route(
|
||||
message(RelayMessage::closed(id, "blocked: not allowed")),
|
||||
&pages,
|
||||
&WatchRegistry::default(),
|
||||
&mut batch,
|
||||
);
|
||||
|
||||
match receiver.try_recv().expect("a report").outcome {
|
||||
Settled::Refused(reason) => assert!(reason.contains("blocked")),
|
||||
other => panic!("expected a refusal, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The SDK re-issues an `auth-required` REQ under the same id after AUTH, so
|
||||
/// the page keeps waiting rather than writing the relay off.
|
||||
#[test]
|
||||
fn an_auth_required_close_settles_nothing() {
|
||||
let pages = PageRegistry::default();
|
||||
let id = SubscriptionId::new("concord-history-1");
|
||||
let (sender, receiver) = flume::bounded(1);
|
||||
pages.register(id.clone(), sender);
|
||||
|
||||
let mut batch = Batch::default();
|
||||
route(
|
||||
message(RelayMessage::closed(
|
||||
id,
|
||||
"auth-required: please authenticate",
|
||||
)),
|
||||
&pages,
|
||||
&WatchRegistry::default(),
|
||||
&mut batch,
|
||||
);
|
||||
|
||||
assert!(receiver.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shutdown_stops_the_pump() {
|
||||
let pages = PageRegistry::default();
|
||||
let mut batch = Batch::default();
|
||||
|
||||
assert_eq!(
|
||||
route(
|
||||
ClientNotification::Shutdown,
|
||||
&pages,
|
||||
&WatchRegistry::default(),
|
||||
&mut batch
|
||||
),
|
||||
Flow::Stop
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,832 +0,0 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::fmt;
|
||||
|
||||
use anyhow::Result;
|
||||
use community::{
|
||||
ChannelId, ChatMessage, Community, CommunityEvent, Epoch, Intent, LOAD_OLDER_PAGES,
|
||||
TIMELINE_PAGE, Timeline,
|
||||
};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, FollowMode,
|
||||
IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, Render, SharedString,
|
||||
Styled, Subscription, Task, WeakEntity, Window, div, list, px,
|
||||
};
|
||||
use nostr_sdk::prelude::EventId;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock::{Panel, PanelEvent};
|
||||
use ui::input::{InputEvent, Textarea, TextareaState};
|
||||
use ui::markdown::RenderedText;
|
||||
use ui::message::WelcomeMessage;
|
||||
use ui::notification::Notification;
|
||||
use ui::scroll::Scrollbar;
|
||||
use ui::{Disableable, IconName, Sizable, WindowExtension, h_flex, v_flex};
|
||||
|
||||
mod message;
|
||||
|
||||
/// How near the top row a scroll has to come before the panel splices older history in.
|
||||
const LOAD_OLDER_THRESHOLD: usize = 20;
|
||||
/// A repeat message within this window keeps its run, so it carries no avatar or name.
|
||||
const RUN_WINDOW_MS: u64 = 300_000;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Notice {
|
||||
Stranded,
|
||||
Removed(Epoch),
|
||||
ChannelRemoved(Epoch),
|
||||
MissingKey(Epoch),
|
||||
Unreachable,
|
||||
Unreadable(usize),
|
||||
}
|
||||
|
||||
impl fmt::Display for Notice {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Notice::Stranded => formatter.write_str(
|
||||
"This invite is stale, the community has rotated past the epoch it names",
|
||||
),
|
||||
Notice::Removed(epoch) => write!(
|
||||
formatter,
|
||||
"You were removed from this community at epoch {}. Its history stays readable",
|
||||
epoch.0
|
||||
),
|
||||
Notice::ChannelRemoved(epoch) => write!(
|
||||
formatter,
|
||||
"A rotation removed you from this channel at epoch {}",
|
||||
epoch.0
|
||||
),
|
||||
Notice::MissingKey(epoch) => write!(
|
||||
formatter,
|
||||
"Messages here can't be read yet, this channel's key for epoch {} is missing",
|
||||
epoch.0
|
||||
),
|
||||
Notice::Unreachable => formatter.write_str("Couldn't reach the community's relays"),
|
||||
Notice::Unreadable(1) => {
|
||||
formatter.write_str("1 message here can't be read yet, no key we hold opens it")
|
||||
}
|
||||
Notice::Unreadable(count) => write!(
|
||||
formatter,
|
||||
"{count} messages here can't be read yet, no key we hold opens them"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Notice {
|
||||
fn writable(self) -> bool {
|
||||
matches!(self, Notice::Unreachable | Notice::Unreadable(_))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(
|
||||
community: Entity<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)
|
||||
rows: Vec<ChatMessage>,
|
||||
/// Rendered markdown content, keyed by message id and dropped when a row changes
|
||||
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
||||
/// Whether the store holds rows older than `rows`
|
||||
has_more: bool,
|
||||
/// A round or a page read is in flight
|
||||
loading: bool,
|
||||
/// Message list state
|
||||
list_state: ListState,
|
||||
/// Message input state
|
||||
input: Entity<TextareaState>,
|
||||
/// Spawned reads and publishes, cancelled when the panel closes
|
||||
tasks: SmallVec<[Task<Result<()>>; 4]>,
|
||||
/// 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.active_channel(),
|
||||
)
|
||||
};
|
||||
|
||||
let input = cx.new(|cx| {
|
||||
TextareaState::new(window, cx)
|
||||
.placeholder(format!("Message {name}"))
|
||||
.auto_grow(1, 20)
|
||||
.clean_on_escape()
|
||||
});
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.send(window, cx);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&community,
|
||||
window,
|
||||
|_this, _community, event, window, cx| {
|
||||
match event {
|
||||
CommunityEvent::Channel(..) => {
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.load(window, cx);
|
||||
});
|
||||
}
|
||||
CommunityEvent::Error(error) => {
|
||||
window.push_notification(Notification::error(error.clone()), cx);
|
||||
}
|
||||
_ => {
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.reload(window, cx);
|
||||
});
|
||||
}
|
||||
};
|
||||
},
|
||||
));
|
||||
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.list_state.set_follow_mode(FollowMode::Tail);
|
||||
this.list_state.set_scroll_handler(cx.listener(
|
||||
|this, event: &ListScrollEvent, window, cx| {
|
||||
if event.visible_range.start <= LOAD_OLDER_THRESHOLD {
|
||||
this.load_older(window, cx);
|
||||
}
|
||||
},
|
||||
));
|
||||
this.load(window, cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
id,
|
||||
focus_handle: cx.focus_handle(),
|
||||
community: community.downgrade(),
|
||||
channel,
|
||||
rows: Vec::new(),
|
||||
rendered_texts_by_id: BTreeMap::new(),
|
||||
has_more: false,
|
||||
loading: false,
|
||||
list_state: ListState::new(0, ListAlignment::Bottom, px(1024.)),
|
||||
input,
|
||||
tasks: smallvec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// The channel to show, following the community's selection.
|
||||
fn resolve_channel(&mut self, cx: &App) -> Option<ChannelId> {
|
||||
let channel = self
|
||||
.community
|
||||
.read_with(cx, |community, _cx| community.active_channel())
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
if channel != self.channel {
|
||||
self.channel = channel;
|
||||
self.rows.clear();
|
||||
self.rendered_texts_by_id.clear();
|
||||
self.has_more = false;
|
||||
self.loading = false;
|
||||
self.list_state.reset(2);
|
||||
}
|
||||
|
||||
channel
|
||||
}
|
||||
|
||||
/// The list's item count: the welcome row, the load-older row, then every message row.
|
||||
fn item_count(&self) -> usize {
|
||||
self.rows.len() + 2
|
||||
}
|
||||
|
||||
/// A timeline read, `before_ms` exclusive, or `None` for the newest rows.
|
||||
fn read(
|
||||
&self,
|
||||
channel: ChannelId,
|
||||
before_ms: Option<u64>,
|
||||
cx: &App,
|
||||
) -> Option<Task<Result<Timeline>>> {
|
||||
self.community
|
||||
.read_with(cx, |community, cx| {
|
||||
community.timeline(&channel, before_ms, TIMELINE_PAGE, cx)
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// A channel round, or `None` once the community is gone.
|
||||
fn sync(
|
||||
&self,
|
||||
channel: ChannelId,
|
||||
intent: Intent,
|
||||
cx: &mut App,
|
||||
) -> Option<Task<Result<community::Progress>>> {
|
||||
self.community
|
||||
.update(cx, |community, cx| {
|
||||
community.sync_channel(&channel, intent, cx)
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Paint the selected channel's cache, then catch it up from the relays.
|
||||
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(channel) = self.resolve_channel(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.reload(window, cx);
|
||||
|
||||
// Opening a channel twice in a breath asks the relays once.
|
||||
if self.due(channel, cx) {
|
||||
self.round(channel, Intent::CatchUp, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the community would actually round `channel`, or just serve it.
|
||||
fn due(&self, channel: ChannelId, cx: &App) -> bool {
|
||||
self.community
|
||||
.read_with(cx, |community, _cx| community.due(&channel))
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Why the room is not showing messages, when it is not simply empty.
|
||||
fn notice(&self, cx: &App) -> Option<Notice> {
|
||||
let channel = self.channel?;
|
||||
|
||||
self.community
|
||||
.read_with(cx, |community, _cx| {
|
||||
if community.stranded() {
|
||||
return Some(Notice::Stranded);
|
||||
}
|
||||
|
||||
if let Some(epoch) = community.removed_at() {
|
||||
return Some(Notice::Removed(epoch));
|
||||
}
|
||||
|
||||
if let Some(epoch) = community.channel_removed_at(&channel) {
|
||||
return Some(Notice::ChannelRemoved(epoch));
|
||||
}
|
||||
|
||||
if let Some(epoch) = community.missing_key(&channel) {
|
||||
return Some(Notice::MissingKey(epoch));
|
||||
}
|
||||
|
||||
if community
|
||||
.progress(&channel)
|
||||
.is_some_and(|progress| progress.failed && progress.errors > 0)
|
||||
{
|
||||
return Some(Notice::Unreachable);
|
||||
}
|
||||
|
||||
let unreadable = community.unreadable(&channel);
|
||||
|
||||
(unreadable > 0).then_some(Notice::Unreadable(unreadable))
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Run a round for `channel`, its completion re-reads the timeline.
|
||||
fn round(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
intent: Intent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(round) = self.sync(channel, intent, cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.loading = true;
|
||||
cx.notify();
|
||||
|
||||
let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| {
|
||||
let result = round.await;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.loading = false;
|
||||
|
||||
if let Err(error) = result {
|
||||
window.push_notification(
|
||||
Notification::error(error.to_string()).autohide(false),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Run the newest round again after a relay failure.
|
||||
fn retry(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(channel) = self.channel else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.round(channel, Intent::CatchUp, window, cx);
|
||||
}
|
||||
|
||||
/// Read the newest page and fold it into what is on screen.
|
||||
fn reload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(channel) = self.resolve_channel(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(timeline) = self.read(channel, None, cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| {
|
||||
match timeline.await {
|
||||
Ok(timeline) => this.update(cx, |this, cx| this.apply(channel, timeline, cx))?,
|
||||
Err(error) => {
|
||||
this.update_in(cx, |_this, window, cx| {
|
||||
window.push_notification(
|
||||
Notification::error(error.to_string()).autohide(false),
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Splice the page of history above the oldest row on screen.
|
||||
fn load_older(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.loading || !self.has_more {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(channel) = self.channel else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(before_ms) = self
|
||||
.rows
|
||||
.first()
|
||||
.map(|message| message.at_ms.saturating_sub(1))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(page) = self.read(channel, Some(before_ms), cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.loading = true;
|
||||
|
||||
let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| {
|
||||
let timeline = match page.await {
|
||||
Ok(timeline) => timeline,
|
||||
Err(error) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.loading = false;
|
||||
window.push_notification(
|
||||
Notification::error(error.to_string()).autohide(false),
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let swept = this.update(cx, |this, cx| {
|
||||
this.prepend(channel, timeline, cx);
|
||||
!this.has_more
|
||||
})?;
|
||||
|
||||
if !swept {
|
||||
this.update(cx, |this, _cx| this.loading = false)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let round = this.update(cx, |this, cx| {
|
||||
this.sync(
|
||||
channel,
|
||||
Intent::Older {
|
||||
pages: LOAD_OLDER_PAGES,
|
||||
},
|
||||
cx,
|
||||
)
|
||||
})?;
|
||||
|
||||
let Some(round) = round else {
|
||||
this.update(cx, |this, _cx| this.loading = false)?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
match round.await {
|
||||
Ok(_) => {
|
||||
let page =
|
||||
this.update(cx, |this, cx| this.read(channel, Some(before_ms), cx))?;
|
||||
|
||||
if let Some(page) = page {
|
||||
match page.await {
|
||||
Ok(timeline) => {
|
||||
this.update(cx, |this, cx| this.prepend(channel, timeline, cx))?
|
||||
}
|
||||
Err(error) => this.update_in(cx, |_this, window, cx| {
|
||||
window.push_notification(
|
||||
Notification::error(error.to_string()).autohide(false),
|
||||
cx,
|
||||
);
|
||||
})?,
|
||||
}
|
||||
}
|
||||
|
||||
this.update(cx, |this, _cx| this.loading = false)?;
|
||||
}
|
||||
Err(error) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.loading = false;
|
||||
window.push_notification(
|
||||
Notification::error(error.to_string()).autohide(false),
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Fold a freshly read window into the rows on screen.
|
||||
fn apply(&mut self, channel: ChannelId, timeline: Timeline, cx: &mut Context<Self>) {
|
||||
if self.channel != Some(channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Timeline { messages, has_more } = timeline;
|
||||
|
||||
let connected = self
|
||||
.rows
|
||||
.last()
|
||||
.is_some_and(|last| messages.iter().any(|message| message.id == last.id));
|
||||
|
||||
if !connected {
|
||||
self.rows = messages;
|
||||
self.rendered_texts_by_id.clear();
|
||||
self.has_more = has_more;
|
||||
self.list_state.reset(self.item_count());
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
self.merge(messages);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Splice older rows in above what is on screen.
|
||||
fn prepend(&mut self, channel: ChannelId, timeline: Timeline, cx: &mut Context<Self>) {
|
||||
if self.channel != Some(channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.has_more = timeline.has_more;
|
||||
self.merge(timeline.messages);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Fold read rows into what is on screen, keeping the list in time order.
|
||||
fn merge(&mut self, messages: Vec<ChatMessage>) {
|
||||
let mut shown: HashMap<EventId, usize> = self
|
||||
.rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, message)| (message.id, ix))
|
||||
.collect();
|
||||
|
||||
let mut fresh = Vec::new();
|
||||
|
||||
for message in messages {
|
||||
match shown.get(&message.id).copied() {
|
||||
Some(ix) => {
|
||||
// Drop the cached render when the text changes, so edits re-parse.
|
||||
if self.rows[ix].content != message.content {
|
||||
self.rendered_texts_by_id.remove(&message.id);
|
||||
}
|
||||
self.rows[ix] = message;
|
||||
}
|
||||
None => fresh.push(message),
|
||||
}
|
||||
}
|
||||
|
||||
for message in fresh {
|
||||
if shown.contains_key(&message.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let at = self
|
||||
.rows
|
||||
.partition_point(|row| (row.at_ms, row.id) <= (message.at_ms, message.id));
|
||||
|
||||
shown.insert(message.id, at);
|
||||
self.rows.insert(at, message);
|
||||
// The welcome and load-older rows sit above the messages.
|
||||
self.list_state.splice(at + 2..at + 2, 1);
|
||||
}
|
||||
}
|
||||
|
||||
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.resolve_channel(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(notice) = self.notice(cx).filter(|notice| !notice.writable()) {
|
||||
window.push_notification(Notification::error(notice.to_string()).autohide(false), cx);
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(send) = self.community.read_with(cx, |community, cx| {
|
||||
community.send(&channel, &content, None, cx)
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(send) = send else {
|
||||
window.push_notification(Notification::error("Failed to send the message"), cx);
|
||||
return;
|
||||
};
|
||||
|
||||
self.input.update(cx, |input, cx| {
|
||||
input.set_value("", window, cx);
|
||||
});
|
||||
|
||||
let task = cx.spawn_in::<_, Result<()>>(window, async move |this, cx| {
|
||||
match send.await {
|
||||
Ok(_) => {
|
||||
this.update_in(cx, |this, window, cx| this.reload(window, cx))?;
|
||||
}
|
||||
Err(error) => {
|
||||
this.update_in(cx, |_this, window, cx| {
|
||||
window.push_notification(
|
||||
Notification::error(error.to_string()).autohide(false),
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// The honest reason this room has nothing to show, with a way out of it.
|
||||
fn render_notice(&self, notice: Notice, cx: &mut Context<Self>) -> AnyElement {
|
||||
h_flex()
|
||||
.w_full()
|
||||
.justify_center()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(notice.to_string())
|
||||
.when(notice == Notice::Unreachable, |this| {
|
||||
this.child(
|
||||
Button::new("retry-round")
|
||||
.label("Retry")
|
||||
.ghost()
|
||||
.small()
|
||||
.loading(self.loading)
|
||||
.on_click(cx.listener(|this, _event, window, cx| this.retry(window, cx))),
|
||||
)
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The row at index 0: the welcome message for the channel.
|
||||
fn render_welcome(&self, cx: &Context<Self>) -> AnyElement {
|
||||
let (name, avatar) = self
|
||||
.community
|
||||
.read_with(cx, |community, _cx| {
|
||||
let seed = community.id().to_hex();
|
||||
let avatar = Avatar::from_source(community.icon()).seed(seed).large();
|
||||
|
||||
(community.name(), avatar)
|
||||
})
|
||||
.unwrap_or_else(|_| (SharedString::from("this community"), Avatar::new(None)));
|
||||
|
||||
WelcomeMessage::new("welcome")
|
||||
.icon(avatar)
|
||||
.title(format!("Welcome to {}", name))
|
||||
.message(format!("This is the start of the {} channel.", name))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The row at index 1: the affordance that pages older history in.
|
||||
fn render_older(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
if !self.has_more {
|
||||
return div().into_any_element();
|
||||
}
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
.justify_center()
|
||||
.py_2()
|
||||
.child(
|
||||
Button::new("load-older")
|
||||
.label(if self.loading {
|
||||
"Loading earlier messages…"
|
||||
} else {
|
||||
"Load earlier messages"
|
||||
})
|
||||
.ghost()
|
||||
.small()
|
||||
.loading(self.loading)
|
||||
.on_click(cx.listener(|this, _event, window, cx| this.load_older(window, cx))),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Whether the row at `index` opens a run from one author.
|
||||
fn opens_run(&self, index: usize) -> bool {
|
||||
let Some(current) = self.rows.get(index) else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let Some(previous) = index.checked_sub(1).and_then(|index| self.rows.get(index)) else {
|
||||
return true;
|
||||
};
|
||||
|
||||
current.author != previous.author
|
||||
|| current.at_ms.saturating_sub(previous.at_ms) > RUN_WINDOW_MS
|
||||
}
|
||||
|
||||
fn render_message(
|
||||
&mut self,
|
||||
ix: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
if ix == 0 {
|
||||
return self.render_welcome(cx);
|
||||
}
|
||||
|
||||
if ix == 1 {
|
||||
return self.render_older(cx);
|
||||
}
|
||||
|
||||
// The welcome and load-older rows sit above the messages.
|
||||
let Some(message) = self.rows.get(ix - 2) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
let show_author = self.opens_run(ix - 2);
|
||||
|
||||
let content = if message.deleted {
|
||||
message::deleted(cx)
|
||||
} else {
|
||||
self.rendered_texts_by_id
|
||||
.entry(message.id)
|
||||
.or_insert_with(|| RenderedText::new(&message.content, &[], true))
|
||||
.element(ix.into(), window, cx)
|
||||
};
|
||||
|
||||
message::render(ix, message, content, show_author, cx)
|
||||
}
|
||||
|
||||
fn render_composer(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let writable = self.notice(cx).is_none_or(|notice| notice.writable());
|
||||
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.w_full()
|
||||
.p_2()
|
||||
.gap_1()
|
||||
.items_end()
|
||||
.child(Textarea::new(&self.input).appearance(false).flex_1())
|
||||
.child(
|
||||
Button::new("send")
|
||||
.icon(IconName::PaperPlaneFill)
|
||||
.tooltip("Send")
|
||||
.ghost()
|
||||
.large()
|
||||
.disabled(!writable)
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
this.send(window, cx);
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for CommunityPanel {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
fn title(&self, cx: &App) -> AnyElement {
|
||||
self.community
|
||||
.read_with(cx, |community, _cx| {
|
||||
let seed = community.id().to_hex();
|
||||
let avatar = Avatar::from_source(community.icon()).seed(seed).xsmall();
|
||||
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.text_xs()
|
||||
.child(avatar)
|
||||
.child(community.name())
|
||||
.into_any_element()
|
||||
})
|
||||
.unwrap_or_else(|_| div().text_xs().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 {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.min_w_0()
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.relative()
|
||||
.map(|this| {
|
||||
let notice = self.notice(cx);
|
||||
|
||||
if self.rows.is_empty() {
|
||||
this.child(
|
||||
v_flex().size_full().justify_center().child(match notice {
|
||||
Some(notice) => self.render_notice(notice, cx),
|
||||
None => h_flex()
|
||||
.size_full()
|
||||
.justify_center()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child("No messages yet")
|
||||
.into_any_element(),
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
this.when_some(notice, |this, notice| {
|
||||
this.child(self.render_notice(notice, cx))
|
||||
})
|
||||
.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))
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use common::TimestampExt;
|
||||
use community::ChatMessage;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, div};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use settings::AppSettings;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::h_flex;
|
||||
use ui::message::MessageRow;
|
||||
|
||||
pub(crate) fn render(
|
||||
ix: usize,
|
||||
message: &ChatMessage,
|
||||
content: AnyElement,
|
||||
show_author: bool,
|
||||
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);
|
||||
|
||||
MessageRow::new(ix)
|
||||
.show_author(show_author)
|
||||
.hide_avatar(hide_avatar)
|
||||
.avatar(
|
||||
Avatar::new(author.avatar())
|
||||
.seed(author.avatar_seed())
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
.author(author.name())
|
||||
.timestamp(Timestamp::from_secs(message.at_ms / 1000).to_human_time())
|
||||
.when(message.edited_at.is_some(), |this| {
|
||||
this.header_extra(div().child("(edited)"))
|
||||
})
|
||||
.child(content)
|
||||
.when(!message.reactions.is_empty(), |this| {
|
||||
this.child(reactions(message, cx))
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The placeholder shown in place of the body of a deleted message.
|
||||
pub(crate) fn deleted(cx: &App) -> AnyElement {
|
||||
div()
|
||||
.text_color(cx.theme().text_danger)
|
||||
.child("Message deleted")
|
||||
.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()
|
||||
}
|
||||
@@ -1,23 +1,20 @@
|
||||
[package]
|
||||
name = "community"
|
||||
name = "nip29"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
concord = { path = "../concord" }
|
||||
nostr.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
futures.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
state = { path = "../state" }
|
||||
chat = { path = "../chat" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
|
||||
anyhow.workspace = true
|
||||
flume.workspace = true
|
||||
futures.workspace = true
|
||||
log.workspace = true
|
||||
serde_json.workspace = true
|
||||
smallvec.workspace = true
|
||||
smol.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
nostr-memory.workspace = true
|
||||
@@ -0,0 +1,748 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use chat::Message;
|
||||
use futures::io::AsyncReadExt;
|
||||
use gpui::http_client::{AsyncBody, Builder, HttpRequestExt, Method, RedirectPolicy, StatusCode};
|
||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
|
||||
use nostr::nips::nip98::{HttpData, HttpMethod};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::NostrRegistry;
|
||||
|
||||
use crate::LOCAL_KEYS;
|
||||
use crate::protocol::{
|
||||
ACTIVITY_KINDS, Activity, GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata,
|
||||
GroupParticipants, GroupPins, GroupRoles, LivekitSession, MEMBERSHIP_KINDS, Membership,
|
||||
PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS, TimelineWindow,
|
||||
};
|
||||
|
||||
const REPLAY_LIMIT: usize = 100;
|
||||
const PAGE_LIMIT: usize = 50;
|
||||
const PAGE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const LIVEKIT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const LEAVE_WINDOW_SECS: u64 = 5 * 60;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Absorbed {
|
||||
Ignored,
|
||||
Shown { fresh: bool, updated: bool },
|
||||
Updated,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Row {
|
||||
Message(Box<Message>),
|
||||
Activity(Activity),
|
||||
}
|
||||
|
||||
impl Row {
|
||||
pub fn id(&self) -> EventId {
|
||||
match self {
|
||||
Self::Message(message) => message.id,
|
||||
Self::Activity(activity) => activity.id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn author(&self) -> PublicKey {
|
||||
match self {
|
||||
Self::Message(message) => message.author,
|
||||
Self::Activity(activity) => activity.author,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn created_at(&self) -> Timestamp {
|
||||
match self {
|
||||
Self::Message(message) => message.created_at,
|
||||
Self::Activity(activity) => activity.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum GroupEvent {
|
||||
Incoming(Event),
|
||||
Sent(Event),
|
||||
Updated,
|
||||
Eose,
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
pub struct Group {
|
||||
key: GroupKey,
|
||||
metadata: Option<GroupMetadata>,
|
||||
members: GroupMembers,
|
||||
admins: GroupAdmins,
|
||||
roles: GroupRoles,
|
||||
participants: GroupParticipants,
|
||||
pins: GroupPins,
|
||||
membership: Membership,
|
||||
membership_events: Vec<Event>,
|
||||
elsewhere: Option<GroupKey>,
|
||||
state_ids: HashMap<Kind, EventId>,
|
||||
rows: Vec<Row>,
|
||||
index: HashSet<EventId>,
|
||||
leave_requests: HashMap<PublicKey, Timestamp>,
|
||||
window: TimelineWindow,
|
||||
oldest: Option<Timestamp>,
|
||||
loading: bool,
|
||||
loading_more: bool,
|
||||
refused: Option<String>,
|
||||
tasks: SmallVec<[Task<()>; 2]>,
|
||||
}
|
||||
|
||||
impl EventEmitter<GroupEvent> for Group {}
|
||||
|
||||
impl Group {
|
||||
pub(crate) fn new(key: GroupKey) -> Self {
|
||||
Self {
|
||||
key,
|
||||
metadata: None,
|
||||
members: GroupMembers::default(),
|
||||
admins: GroupAdmins::default(),
|
||||
roles: GroupRoles::default(),
|
||||
participants: GroupParticipants::default(),
|
||||
pins: GroupPins::default(),
|
||||
membership: Membership::Unknown,
|
||||
membership_events: Vec::new(),
|
||||
elsewhere: None,
|
||||
state_ids: HashMap::new(),
|
||||
rows: Vec::new(),
|
||||
index: HashSet::new(),
|
||||
leave_requests: HashMap::new(),
|
||||
window: TimelineWindow::new(),
|
||||
oldest: None,
|
||||
loading: true,
|
||||
loading_more: false,
|
||||
refused: None,
|
||||
tasks: smallvec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn key(&self) -> &GroupKey {
|
||||
&self.key
|
||||
}
|
||||
|
||||
pub fn metadata(&self) -> Option<&GroupMetadata> {
|
||||
self.metadata.as_ref()
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> SharedString {
|
||||
let name = self
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.name())
|
||||
.unwrap_or_else(|| self.key.id().as_str());
|
||||
|
||||
SharedString::from(name.to_owned())
|
||||
}
|
||||
|
||||
pub fn display_image(&self) -> Option<SharedString> {
|
||||
self.metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.picture.clone())
|
||||
.map(SharedString::from)
|
||||
}
|
||||
|
||||
pub fn total_members(&self) -> usize {
|
||||
self.members.len()
|
||||
}
|
||||
|
||||
pub fn members(&self) -> &GroupMembers {
|
||||
&self.members
|
||||
}
|
||||
|
||||
pub fn admins(&self) -> &GroupAdmins {
|
||||
&self.admins
|
||||
}
|
||||
|
||||
pub fn roles(&self) -> &GroupRoles {
|
||||
&self.roles
|
||||
}
|
||||
|
||||
pub fn participants(&self) -> &GroupParticipants {
|
||||
&self.participants
|
||||
}
|
||||
|
||||
pub fn pins(&self) -> &GroupPins {
|
||||
&self.pins
|
||||
}
|
||||
|
||||
pub fn membership(&self) -> &Membership {
|
||||
&self.membership
|
||||
}
|
||||
|
||||
pub fn elsewhere(&self) -> Option<&GroupKey> {
|
||||
self.elsewhere.as_ref()
|
||||
}
|
||||
|
||||
pub fn rows(&self) -> &[Row] {
|
||||
&self.rows
|
||||
}
|
||||
|
||||
pub fn message(&self, id: EventId) -> Option<&Message> {
|
||||
self.rows.iter().find_map(|row| match row {
|
||||
Row::Message(message) if message.id == id => Some(message.as_ref()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn loading(&self) -> bool {
|
||||
self.loading
|
||||
}
|
||||
|
||||
pub fn loading_more(&self) -> bool {
|
||||
self.loading_more
|
||||
}
|
||||
|
||||
pub fn oldest(&self) -> Option<Timestamp> {
|
||||
self.oldest
|
||||
}
|
||||
|
||||
pub fn refused(&self) -> Option<&str> {
|
||||
self.refused.as_deref()
|
||||
}
|
||||
|
||||
pub fn previous_tag(&self, me: PublicKey) -> Option<Tag> {
|
||||
self.window.previous_tag(me)
|
||||
}
|
||||
|
||||
pub fn send_message(
|
||||
&mut self,
|
||||
content: &str,
|
||||
replies: Vec<EventId>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.message(content, &replies, prev), cx)
|
||||
}
|
||||
|
||||
pub fn send_reaction(
|
||||
&mut self,
|
||||
target: EventId,
|
||||
emoji: String,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.reaction(target, &emoji, prev), cx)
|
||||
}
|
||||
|
||||
pub fn put_user(
|
||||
&mut self,
|
||||
public_key: PublicKey,
|
||||
roles: Vec<String>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.put_user(public_key, &roles, prev), cx)
|
||||
}
|
||||
|
||||
pub fn remove_user(
|
||||
&mut self,
|
||||
public_key: PublicKey,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.remove_user(public_key, prev), cx)
|
||||
}
|
||||
|
||||
pub fn edit_metadata(
|
||||
&mut self,
|
||||
metadata: &GroupMetadata,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.edit_metadata(metadata, prev), cx)
|
||||
}
|
||||
|
||||
pub fn delete_event(&mut self, id: EventId, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
self.dispatch(move |gid, prev| gid.delete_event(id, prev), cx)
|
||||
}
|
||||
|
||||
pub fn create_invite(&mut self, code: &str, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.create_invite(code, prev), cx)
|
||||
}
|
||||
|
||||
pub fn update_pins(&mut self, pins: Vec<Pin>, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.update_pin_list(&pins, prev), cx)
|
||||
}
|
||||
|
||||
fn dispatch<F>(&mut self, build: F, cx: &mut Context<Self>) -> Task<Result<()>>
|
||||
where
|
||||
F: FnOnce(&GroupId, Option<Tag>) -> EventBuilder,
|
||||
{
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
let Some(me) = nostr.read(cx).current_user() else {
|
||||
return Task::ready(Err(anyhow!("nip29: no signed-in account")));
|
||||
};
|
||||
|
||||
let builder = build(self.key.id(), self.window.previous_tag(me));
|
||||
let published = self.publish(builder, cx);
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let event = published.await?;
|
||||
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(GroupEvent::Sent(event));
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn publish(&self, builder: EventBuilder, cx: &App) -> Task<Result<Event>> {
|
||||
let nostr = NostrRegistry::global(cx).read(cx);
|
||||
let client = nostr.client();
|
||||
let signer = nostr.signer();
|
||||
let relay = self.key.relay().clone();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
if let Err(error) = client.add_relay(relay.clone()).and_connect().await {
|
||||
log::warn!("nip29: connecting {relay} failed: {error}");
|
||||
}
|
||||
|
||||
let event = builder.finalize_async(&signer).await?;
|
||||
let output = client.send_event(&event).to([relay]).await?;
|
||||
|
||||
if let Some(reason) = output.failed.into_values().next() {
|
||||
bail!("{reason}");
|
||||
}
|
||||
|
||||
Ok(event)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn mark_pending(&mut self, since: Timestamp, cx: &mut Context<Self>) {
|
||||
if self.membership != Membership::Member {
|
||||
self.membership = Membership::Pending { since };
|
||||
cx.emit(GroupEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mark_refused(&mut self, reason: String, cx: &mut Context<Self>) {
|
||||
if self.membership == Membership::Member {
|
||||
return;
|
||||
}
|
||||
self.membership = Membership::Refused {
|
||||
reason: Some(reason),
|
||||
};
|
||||
cx.emit(GroupEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn set_elsewhere(&mut self, elsewhere: Option<GroupKey>, cx: &mut Context<Self>) {
|
||||
if self.elsewhere == elsewhere {
|
||||
return;
|
||||
}
|
||||
self.elsewhere = elsewhere;
|
||||
cx.emit(GroupEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn connect(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let Some(me) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let key = self.key.clone();
|
||||
let id = crate::subscription_id(&key);
|
||||
let relay = key.relay().clone();
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
if let Err(error) = client.add_relay(relay.clone()).and_connect().await {
|
||||
log::warn!("nip29: connecting {relay} failed: {error}");
|
||||
}
|
||||
|
||||
let filters = [
|
||||
key.id().timeline_filter(REPLAY_LIMIT),
|
||||
key.id().membership_filter(me),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(key.id().state_filters())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if let Err(error) = client
|
||||
.subscribe(ReqTarget::single(relay, filters))
|
||||
.with_id(id)
|
||||
.await
|
||||
{
|
||||
log::warn!("nip29: subscribing to {} failed: {error}", key.id());
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
pub(crate) fn load_page(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(until) = self.oldest else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.loading_more = true;
|
||||
cx.notify();
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let id = crate::page_subscription(&self.key);
|
||||
let relay = self.key.relay().clone();
|
||||
let filter = self.key.id().timeline_page(PAGE_LIMIT, until);
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
client.add_relay(relay.clone()).and_connect().await.ok();
|
||||
|
||||
let opts = SubscribeAutoCloseOptions::default()
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||
.timeout(Some(PAGE_TIMEOUT));
|
||||
|
||||
if let Err(error) = client
|
||||
.subscribe(ReqTarget::single(relay, [filter]))
|
||||
.with_id(id)
|
||||
.close_on(opts)
|
||||
.await
|
||||
{
|
||||
log::warn!("nip29: loading older messages failed: {error}");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
pub(crate) fn page_finished(&mut self, cx: &mut Context<Self>) {
|
||||
self.loading_more = false;
|
||||
cx.emit(GroupEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn livekit_session(&self, cx: &App) -> Task<Result<LivekitSession>> {
|
||||
let url = match self.key.livekit_token_url() {
|
||||
Ok(url) => url,
|
||||
Err(error) => return Task::ready(Err(error)),
|
||||
};
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
let client = cx.http_client();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let authorization = HttpData::new(url.clone(), HttpMethod::GET)
|
||||
.to_authorization(&signer)
|
||||
.await?;
|
||||
|
||||
let request = Builder::new()
|
||||
.uri(url.as_str())
|
||||
.method(Method::GET)
|
||||
.header("Authorization", authorization)
|
||||
.follow_redirects(RedirectPolicy::NoFollow)
|
||||
.timeout(LIVEKIT_TIMEOUT)
|
||||
.body(AsyncBody::default())?;
|
||||
|
||||
let response = client.send(request).await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
bail!(
|
||||
"nip29: the AV token endpoint answered {}",
|
||||
response.status()
|
||||
);
|
||||
}
|
||||
|
||||
let mut body = Vec::new();
|
||||
response.into_body().read_to_end(&mut body).await?;
|
||||
|
||||
LivekitSession::parse(&body)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn livekit_supported(&self, cx: &App) -> Task<Result<bool>> {
|
||||
let url = match self.key.livekit_probe_url() {
|
||||
Ok(url) => url,
|
||||
Err(error) => return Task::ready(Err(error)),
|
||||
};
|
||||
|
||||
let client = cx.http_client();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let request = Builder::new()
|
||||
.uri(url.as_str())
|
||||
.method(Method::GET)
|
||||
.follow_redirects(RedirectPolicy::NoFollow)
|
||||
.timeout(LIVEKIT_TIMEOUT)
|
||||
.body(AsyncBody::default())?;
|
||||
|
||||
let response = client.send(request).await?;
|
||||
|
||||
Ok(response.status() == StatusCode::NO_CONTENT)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn save_envelope(&self, event: Event, cx: &App) -> Task<Result<()>> {
|
||||
let cache_tag = self.key.cache_tag();
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let tags = vec![
|
||||
Tag::identifier(event.id),
|
||||
Tag::custom("r", [cache_tag]),
|
||||
Tag::custom("k", [event.kind.to_string()]),
|
||||
];
|
||||
|
||||
let envelope = EventBuilder::new(Kind::ApplicationSpecificData, event.as_json())
|
||||
.tags(tags)
|
||||
.finalize_async(&*LOCAL_KEYS)
|
||||
.await?;
|
||||
|
||||
client.database().save_event(&envelope).await?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn load_envelopes(&self, cx: &App) -> Task<Result<Vec<Event>>> {
|
||||
let cache_tag = self.key.cache_tag();
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(SingleLetterTag::LOWERCASE_R, cache_tag);
|
||||
|
||||
let mut events: Vec<Event> = client
|
||||
.database()
|
||||
.query(filter)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|envelope| Event::from_json(&envelope.content).ok())
|
||||
.collect();
|
||||
|
||||
events.sort_by_key(|event| event.created_at);
|
||||
|
||||
Ok(events)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn hydrate(&mut self, events: Vec<Event>, me: PublicKey, cx: &mut Context<Self>) {
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut rendered = Vec::new();
|
||||
let mut changed = false;
|
||||
|
||||
for event in &events {
|
||||
match self.absorb(event, me) {
|
||||
Absorbed::Shown {
|
||||
fresh: true,
|
||||
updated,
|
||||
} => {
|
||||
rendered.push(event.clone());
|
||||
changed |= updated;
|
||||
}
|
||||
Absorbed::Shown {
|
||||
fresh: false,
|
||||
updated,
|
||||
} => changed |= updated,
|
||||
Absorbed::Updated => changed = true,
|
||||
Absorbed::Ignored => {}
|
||||
}
|
||||
}
|
||||
|
||||
for event in rendered {
|
||||
cx.emit(GroupEvent::Incoming(event));
|
||||
}
|
||||
|
||||
if changed {
|
||||
cx.emit(GroupEvent::Updated);
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn forget(&mut self, id: EventId, cx: &mut Context<Self>) {
|
||||
self.window.remove(id);
|
||||
|
||||
if !self.index.remove(&id) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.rows.retain(|row| row.id() != id);
|
||||
cx.emit(GroupEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn eose(&mut self, cx: &mut Context<Self>) {
|
||||
self.loading = false;
|
||||
cx.emit(GroupEvent::Eose);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn refuse(&mut self, reason: String, cx: &mut Context<Self>) {
|
||||
self.loading = false;
|
||||
self.refused = Some(reason.clone());
|
||||
cx.emit(GroupEvent::Refused(reason));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn absorb(&mut self, event: &Event, me: PublicKey) -> Absorbed {
|
||||
if STATE_KINDS.contains(&event.kind) {
|
||||
return if self.absorb_state(event) {
|
||||
Absorbed::Updated
|
||||
} else {
|
||||
Absorbed::Ignored
|
||||
};
|
||||
}
|
||||
|
||||
self.track(event);
|
||||
|
||||
let updated = self.absorb_membership(event, me);
|
||||
|
||||
if ACTIVITY_KINDS.contains(&event.kind) {
|
||||
return Absorbed::Shown {
|
||||
fresh: self.push_activity(event),
|
||||
updated,
|
||||
};
|
||||
}
|
||||
|
||||
if RENDER_KINDS.contains(&event.kind) {
|
||||
return Absorbed::Shown {
|
||||
fresh: self.push_row(event),
|
||||
updated,
|
||||
};
|
||||
}
|
||||
|
||||
if updated {
|
||||
Absorbed::Updated
|
||||
} else {
|
||||
Absorbed::Ignored
|
||||
}
|
||||
}
|
||||
|
||||
fn absorb_membership(&mut self, event: &Event, me: PublicKey) -> bool {
|
||||
if !MEMBERSHIP_KINDS.contains(&event.kind) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if !event.tags.public_keys().any(|key| key == me) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if !self
|
||||
.membership_events
|
||||
.iter()
|
||||
.any(|existing| existing.id == event.id)
|
||||
{
|
||||
self.membership_events.push(event.clone());
|
||||
}
|
||||
|
||||
self.refresh_membership(me)
|
||||
}
|
||||
|
||||
fn track(&mut self, event: &Event) {
|
||||
self.window.push(event.clone());
|
||||
|
||||
if self.oldest.is_none_or(|oldest| event.created_at < oldest) {
|
||||
self.oldest = Some(event.created_at);
|
||||
}
|
||||
}
|
||||
|
||||
fn absorb_state(&mut self, event: &Event) -> bool {
|
||||
match event.kind {
|
||||
Kind::GroupMetadata => match GroupMetadata::parse(event) {
|
||||
Ok(metadata) => self.metadata = Some(metadata),
|
||||
Err(_) => return false,
|
||||
},
|
||||
Kind::GroupAdmins => match GroupAdmins::parse(event) {
|
||||
Ok(admins) => self.admins = admins,
|
||||
Err(_) => return false,
|
||||
},
|
||||
Kind::GroupMembers => match GroupMembers::parse(event) {
|
||||
Ok(members) => self.members = members,
|
||||
Err(_) => return false,
|
||||
},
|
||||
Kind::GroupRoles => match GroupRoles::parse(event) {
|
||||
Ok(roles) => self.roles = roles,
|
||||
Err(_) => return false,
|
||||
},
|
||||
Kind::GroupLivekitParticipants => match GroupParticipants::parse(event) {
|
||||
Ok(participants) => self.participants = participants,
|
||||
Err(_) => return false,
|
||||
},
|
||||
Kind::Custom(PIN_LIST) => match GroupPins::parse(event) {
|
||||
Ok(pins) => self.pins = pins,
|
||||
Err(_) => return false,
|
||||
},
|
||||
_ => return false,
|
||||
}
|
||||
|
||||
self.state_ids.insert(event.kind, event.id) != Some(event.id)
|
||||
}
|
||||
|
||||
fn push_row(&mut self, event: &Event) -> bool {
|
||||
if !self.index.insert(event.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let message = Message::from(event);
|
||||
let position = self
|
||||
.rows
|
||||
.partition_point(|row| row.created_at() <= message.created_at);
|
||||
|
||||
self.rows.insert(position, Row::Message(Box::new(message)));
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn push_activity(&mut self, event: &Event) -> bool {
|
||||
let activity = match Activity::parse(event) {
|
||||
Ok(activity) => activity,
|
||||
Err(error) => {
|
||||
log::warn!("nip29: ignoring a malformed group activity: {error}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if activity.kind == Kind::GroupPutUser && activity.roles.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if activity.kind == Kind::GroupRemoveUser
|
||||
&& self
|
||||
.leave_requests
|
||||
.get(&activity.subject())
|
||||
.is_some_and(|left| {
|
||||
activity.created_at >= *left
|
||||
&& activity.created_at.as_secs().saturating_sub(left.as_secs())
|
||||
<= LEAVE_WINDOW_SECS
|
||||
})
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if !self.index.insert(activity.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if activity.kind == Kind::GroupLeaveRequest {
|
||||
self.leave_requests
|
||||
.insert(activity.subject(), activity.created_at);
|
||||
}
|
||||
|
||||
let position = self
|
||||
.rows
|
||||
.partition_point(|row| row.created_at() <= activity.created_at);
|
||||
|
||||
self.rows.insert(position, Row::Activity(activity));
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn refresh_membership(&mut self, me: PublicKey) -> bool {
|
||||
let derived = Membership::from_events(&self.membership_events, me);
|
||||
|
||||
if derived != Membership::Unknown && derived != self.membership {
|
||||
self.membership = derived;
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,909 @@
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Error, Result};
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::NostrRegistry;
|
||||
|
||||
mod group;
|
||||
mod protocol;
|
||||
|
||||
pub use group::*;
|
||||
pub use protocol::*;
|
||||
|
||||
pub(crate) static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
||||
|
||||
const LIST_SUBSCRIPTION: &str = "nip29-list";
|
||||
const FORK_SUBSCRIPTION: &str = "nip29-forks";
|
||||
const GROUP_PREFIX: &str = "grp-";
|
||||
const BROWSE_PREFIX: &str = "brw-";
|
||||
const PAGE_PREFIX: &str = "pg-";
|
||||
const BROWSE_LIMIT: usize = 500;
|
||||
const BROWSE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const FORK_INTERVAL: Duration = Duration::from_secs(15 * 60);
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
GroupsRegistry::set_global(cx.new(GroupsRegistry::new), cx);
|
||||
}
|
||||
|
||||
struct GlobalGroupsRegistry(Entity<GroupsRegistry>);
|
||||
|
||||
impl Global for GlobalGroupsRegistry {}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum GroupsEvent {
|
||||
Updated,
|
||||
Error(String),
|
||||
Browsed(Box<GroupCandidate>),
|
||||
BrowseSettled(RelayUrl),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Signal {
|
||||
Event(SubscriptionId, Event),
|
||||
Eose(SubscriptionId),
|
||||
Closed(SubscriptionId, String),
|
||||
BrowseSettled(SubscriptionId),
|
||||
Forks,
|
||||
}
|
||||
|
||||
fn list_subscription_id() -> SubscriptionId {
|
||||
SubscriptionId::new(LIST_SUBSCRIPTION)
|
||||
}
|
||||
|
||||
fn fork_subscription_id() -> SubscriptionId {
|
||||
SubscriptionId::new(FORK_SUBSCRIPTION)
|
||||
}
|
||||
|
||||
fn subscription_id(key: &GroupKey) -> SubscriptionId {
|
||||
SubscriptionId::new(format!("{GROUP_PREFIX}{:08x}", key.uniq_id() as u32))
|
||||
}
|
||||
|
||||
fn browse_subscription(relay: &RelayUrl) -> SubscriptionId {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
relay.hash(&mut hasher);
|
||||
|
||||
SubscriptionId::new(format!("{BROWSE_PREFIX}{:08x}", hasher.finish() as u32))
|
||||
}
|
||||
|
||||
fn page_subscription(key: &GroupKey) -> SubscriptionId {
|
||||
SubscriptionId::new(format!("{PAGE_PREFIX}{:08x}", key.uniq_id() as u32))
|
||||
}
|
||||
|
||||
fn is_page(id: &SubscriptionId) -> bool {
|
||||
id.as_str().starts_with(PAGE_PREFIX)
|
||||
}
|
||||
|
||||
fn is_own_subscription(id: &SubscriptionId) -> bool {
|
||||
let id = id.as_str();
|
||||
id == LIST_SUBSCRIPTION
|
||||
|| id == FORK_SUBSCRIPTION
|
||||
|| id.starts_with(GROUP_PREFIX)
|
||||
|| id.starts_with(BROWSE_PREFIX)
|
||||
|| id.starts_with(PAGE_PREFIX)
|
||||
}
|
||||
|
||||
impl EventEmitter<GroupsEvent> for GroupsRegistry {}
|
||||
|
||||
pub struct GroupsRegistry {
|
||||
groups: Vec<Entity<Group>>,
|
||||
list: GroupList,
|
||||
browse: HashMap<SubscriptionId, RelayUrl>,
|
||||
routes: HashMap<SubscriptionId, GroupKey>,
|
||||
observers: HashMap<GroupKey, Subscription>,
|
||||
forks: HashMap<PublicKey, (Timestamp, GroupList)>,
|
||||
fork_authors: HashSet<PublicKey>,
|
||||
signal_tx: flume::Sender<Signal>,
|
||||
signal_rx: flume::Receiver<Signal>,
|
||||
notification_listener: Option<Task<()>>,
|
||||
signal_consumer: Option<Task<()>>,
|
||||
tasks: SmallVec<[Task<()>; 2]>,
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
|
||||
impl GroupsRegistry {
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalGroupsRegistry>().0.clone()
|
||||
}
|
||||
|
||||
fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalGroupsRegistry(state));
|
||||
}
|
||||
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let this = cx.entity().downgrade();
|
||||
|
||||
let (tx, rx) = flume::unbounded::<Signal>();
|
||||
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.load(cx);
|
||||
}
|
||||
}));
|
||||
|
||||
cx.defer(move |cx| {
|
||||
if let Err(error) = this.update(cx, |this, cx| {
|
||||
this.handle_notifications(cx);
|
||||
|
||||
if nostr.read(cx).current_user().is_some() {
|
||||
this.load(cx);
|
||||
}
|
||||
}) {
|
||||
log::warn!("nip29: initializing the registry failed: {error}");
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
groups: Vec::new(),
|
||||
list: GroupList::default(),
|
||||
browse: HashMap::new(),
|
||||
routes: HashMap::new(),
|
||||
observers: HashMap::new(),
|
||||
forks: HashMap::new(),
|
||||
fork_authors: HashSet::new(),
|
||||
signal_tx: tx,
|
||||
signal_rx: rx,
|
||||
notification_listener: None,
|
||||
signal_consumer: None,
|
||||
tasks: smallvec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn groups(&self) -> &[Entity<Group>] {
|
||||
&self.groups
|
||||
}
|
||||
|
||||
pub fn group(&self, key: &GroupKey, cx: &App) -> Option<Entity<Group>> {
|
||||
self.groups
|
||||
.iter()
|
||||
.find(|group| group.read(cx).key() == key)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn join(&mut self, reference: GroupReference, cx: &mut Context<Self>) {
|
||||
self.join_key(reference.key(), reference.invite().map(str::to_owned), cx);
|
||||
}
|
||||
|
||||
pub fn join_key(&mut self, key: GroupKey, invite: Option<String>, cx: &mut Context<Self>) {
|
||||
self.ensure(key.clone(), cx);
|
||||
|
||||
let Some(group) = self.group(&key, cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.upsert_entry(&key, None);
|
||||
self.publish_list(cx);
|
||||
|
||||
cx.emit(GroupsEvent::Updated);
|
||||
cx.notify();
|
||||
|
||||
let builder = key.id().join_request(invite.as_deref(), "");
|
||||
let sent = group.read(cx).publish(builder, cx);
|
||||
|
||||
group.update(cx, |group, cx| {
|
||||
group.mark_pending(Timestamp::now(), cx);
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if let Err(error) = sent.await {
|
||||
let reason = error.to_string();
|
||||
|
||||
if let Err(error) = this.update(cx, |this, cx| {
|
||||
if let Some(group) = this.group(&key, cx) {
|
||||
group.update(cx, |group, cx| {
|
||||
group.mark_refused(reason, cx);
|
||||
});
|
||||
}
|
||||
}) {
|
||||
log::warn!("nip29: marking the refused join failed: {error}");
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
pub fn create(&mut self, key: GroupKey, metadata: GroupMetadata, cx: &mut Context<Self>) {
|
||||
self.ensure(key.clone(), cx);
|
||||
|
||||
let Some(group) = self.group(&key, cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.upsert_entry(&key, metadata.name().map(str::to_owned));
|
||||
self.publish_list(cx);
|
||||
|
||||
cx.emit(GroupsEvent::Updated);
|
||||
cx.notify();
|
||||
|
||||
let builder = key.id().create_group(&metadata, None);
|
||||
let published = group.read(cx).publish(builder, cx);
|
||||
let logged = key.clone();
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if let Err(e) = published.await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.forget(logged, cx);
|
||||
cx.emit(GroupsEvent::Error(e.to_string()));
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
pub fn browse(&mut self, relay: RelayUrl, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let id = browse_subscription(&relay);
|
||||
let tx = self.signal_tx.clone();
|
||||
|
||||
self.browse.insert(id.clone(), relay.clone());
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
client.add_relay(relay.clone()).and_connect().await.ok();
|
||||
|
||||
let filter = Filter::new().kind(Kind::GroupMetadata).limit(BROWSE_LIMIT);
|
||||
let opts = SubscribeAutoCloseOptions::default()
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||
.timeout(Some(BROWSE_TIMEOUT));
|
||||
|
||||
if let Err(error) = client
|
||||
.subscribe(ReqTarget::single(relay.clone(), [filter]))
|
||||
.with_id(id.clone())
|
||||
.close_on(opts)
|
||||
.await
|
||||
{
|
||||
log::warn!("nip29: browsing {relay} failed: {error}");
|
||||
}
|
||||
|
||||
if let Err(error) = tx.send_async(Signal::BrowseSettled(id)).await {
|
||||
log::warn!("nip29: the browse settle signal was dropped: {error}");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
fn settle_browse(&mut self, id: SubscriptionId, cx: &mut Context<Self>) {
|
||||
let Some(relay) = self.browse.get(&id).cloned() else {
|
||||
return;
|
||||
};
|
||||
cx.emit(GroupsEvent::BrowseSettled(relay));
|
||||
}
|
||||
|
||||
pub fn load_more(&mut self, key: &GroupKey, cx: &mut Context<Self>) {
|
||||
let Some(group) = self.group(key, cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if group.read(cx).loading_more() || group.read(cx).oldest().is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.routes.insert(page_subscription(key), key.clone());
|
||||
|
||||
group.update(cx, |group, cx| {
|
||||
group.load_page(cx);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn leave(&mut self, key: &GroupKey, cx: &mut Context<Self>) {
|
||||
let Some(group) = self.group(key, cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let builder = key.id().leave_request("");
|
||||
let sent = group.read_with(cx, |group, cx| group.publish(builder, cx));
|
||||
let key = key.clone();
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if let Err(error) = sent.await {
|
||||
log::warn!("nip29: leaving {} failed: {error}", key.id());
|
||||
return;
|
||||
}
|
||||
this.update(cx, |this, cx| {
|
||||
this.forget(key, cx);
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
pub fn reset(&mut self, cx: &mut Context<Self>) {
|
||||
self.notification_listener = None;
|
||||
self.signal_consumer = None;
|
||||
self.tasks.clear();
|
||||
|
||||
let routes = std::mem::take(&mut self.routes);
|
||||
|
||||
self.groups.clear();
|
||||
self.observers.clear();
|
||||
self.forks.clear();
|
||||
self.fork_authors.clear();
|
||||
self.list = GroupList::default();
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let mut ids: Vec<SubscriptionId> = routes.into_keys().collect();
|
||||
ids.push(list_subscription_id());
|
||||
ids.push(fork_subscription_id());
|
||||
ids.extend(self.browse.keys().cloned());
|
||||
|
||||
self.browse.clear();
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
for id in ids {
|
||||
if let Err(error) = client.unsubscribe(&id).await {
|
||||
log::warn!("nip29: unsubscribing {id} failed: {error}");
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn load(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
let task: Task<Result<GroupList, Error>> = cx.background_spawn(async move {
|
||||
let me = signer.get_public_key_async().await?;
|
||||
let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1);
|
||||
|
||||
client
|
||||
.subscribe(ReqTarget::auto([filter.clone()]))
|
||||
.with_id(list_subscription_id())
|
||||
.await?;
|
||||
|
||||
match client.database().query(filter).await?.into_iter().next() {
|
||||
Some(event) => GroupList::parse(&event),
|
||||
None => Ok(GroupList::default()),
|
||||
}
|
||||
});
|
||||
|
||||
self.tasks
|
||||
.push(cx.spawn(async move |this, cx| match task.await {
|
||||
Ok(list) => {
|
||||
if let Err(error) = this.update(cx, |this, cx| this.merge(list, cx)) {
|
||||
log::warn!("nip29: applying the group list failed: {error}");
|
||||
}
|
||||
}
|
||||
Err(error) => log::warn!("nip29: loading the group list failed: {error}"),
|
||||
}));
|
||||
|
||||
self.watch_forks(cx);
|
||||
}
|
||||
|
||||
fn merge(&mut self, list: GroupList, cx: &mut Context<Self>) {
|
||||
let entries = list.entries().to_vec();
|
||||
|
||||
for entry in entries {
|
||||
self.list.upsert(entry.clone());
|
||||
self.ensure(GroupKey::new(entry.relay.clone(), entry.id.clone()), cx);
|
||||
}
|
||||
|
||||
cx.emit(GroupsEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn watch_forks(&mut self, cx: &mut Context<Self>) {
|
||||
let executor = cx.background_executor().clone();
|
||||
let tx = self.signal_tx.clone();
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
loop {
|
||||
executor.timer(FORK_INTERVAL).await;
|
||||
|
||||
if tx.send_async(Signal::Forks).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
fn admin_keys(&self, cx: &App) -> HashSet<PublicKey> {
|
||||
let mut keys = HashSet::new();
|
||||
|
||||
for group in &self.groups {
|
||||
for (admin, _) in group.read(cx).admins().entries() {
|
||||
keys.insert(*admin);
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
}
|
||||
|
||||
fn sync_forks(&mut self, force: bool, cx: &mut Context<Self>) {
|
||||
let authors = self.admin_keys(cx);
|
||||
|
||||
if authors.is_empty() || (!force && authors == self.fork_authors) {
|
||||
return;
|
||||
}
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let filter = Filter::new()
|
||||
.authors(authors.iter().copied())
|
||||
.kind(Kind::SimpleGroups);
|
||||
|
||||
self.fork_authors = authors;
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
if let Err(error) = client
|
||||
.subscribe(ReqTarget::auto([filter]))
|
||||
.with_id(fork_subscription_id())
|
||||
.await
|
||||
{
|
||||
log::warn!("nip29: subscribing to the admins' group lists failed: {error}");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
fn absorb_fork_list(&mut self, event: &Event, cx: &mut Context<Self>) {
|
||||
let Ok(list) = GroupList::parse(event) else {
|
||||
log::warn!("nip29: ignoring a malformed admin group list");
|
||||
return;
|
||||
};
|
||||
|
||||
let author = event.pubkey;
|
||||
|
||||
if let Some((seen, _)) = self.forks.get(&author)
|
||||
&& *seen >= event.created_at
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.forks.insert(author, (event.created_at, list));
|
||||
self.evaluate_forks(cx);
|
||||
}
|
||||
|
||||
fn evaluate_forks(&mut self, cx: &mut Context<Self>) {
|
||||
let groups = self.groups.clone();
|
||||
|
||||
for group in groups {
|
||||
let elsewhere = {
|
||||
let group = group.read(cx);
|
||||
let key = group.key();
|
||||
|
||||
group
|
||||
.admins()
|
||||
.entries()
|
||||
.iter()
|
||||
.filter_map(|(admin, _)| self.forks.get(admin))
|
||||
.flat_map(|(_, list)| list.entries())
|
||||
.find(|entry| entry.id == *key.id() && entry.relay != *key.relay())
|
||||
.map(|entry| GroupKey::new(entry.relay.clone(), entry.id.clone()))
|
||||
};
|
||||
|
||||
group.update(cx, |group, cx| {
|
||||
group.set_elsewhere(elsewhere, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure(&mut self, key: GroupKey, cx: &mut Context<Self>) {
|
||||
if self.group(&key, cx).is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let group = cx.new(|_| Group::new(key.clone()));
|
||||
self.groups.push(group.clone());
|
||||
|
||||
self.observers.insert(
|
||||
key.clone(),
|
||||
cx.subscribe(&group, |this, group, event, cx| match event {
|
||||
GroupEvent::Sent(event) => {
|
||||
if event.kind == Kind::GroupDeleteEvent {
|
||||
this.forget_event(group, event.clone(), cx);
|
||||
} else {
|
||||
let key = group.read(cx).key().clone();
|
||||
this.reload(key, Some(event.clone()), cx);
|
||||
}
|
||||
}
|
||||
GroupEvent::Updated => {
|
||||
this.refresh_entry(group, cx);
|
||||
this.sync_forks(false, cx);
|
||||
|
||||
cx.emit(GroupsEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
_ => {}
|
||||
}),
|
||||
);
|
||||
|
||||
self.subscribe(key.clone(), cx);
|
||||
self.reload(key, None, cx);
|
||||
}
|
||||
|
||||
fn subscribe(&mut self, key: GroupKey, cx: &mut Context<Self>) {
|
||||
let Some(group) = self.group(&key, cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.routes.insert(subscription_id(&key), key);
|
||||
|
||||
group.update(cx, |group, cx| {
|
||||
group.connect(cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn forget(&mut self, key: GroupKey, cx: &mut Context<Self>) {
|
||||
let id = subscription_id(&key);
|
||||
let page = page_subscription(&key);
|
||||
let cache_tag = key.cache_tag();
|
||||
|
||||
self.routes.remove(&id);
|
||||
self.routes.remove(&page);
|
||||
self.observers.remove(&key);
|
||||
self.groups.retain(|group| group.read(cx).key() != &key);
|
||||
self.list.remove(&key);
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
self.tasks.push(cx.background_spawn(async move {
|
||||
for id in [id, page] {
|
||||
if let Err(error) = client.unsubscribe(&id).await {
|
||||
log::warn!("nip29: unsubscribing {id} failed: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(SingleLetterTag::LOWERCASE_R, cache_tag);
|
||||
|
||||
if let Err(error) = client.database().delete(filter).await {
|
||||
log::warn!("nip29: clearing the group cache failed: {error}");
|
||||
}
|
||||
}));
|
||||
|
||||
self.publish_list(cx);
|
||||
|
||||
cx.emit(GroupsEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn entry_name(&self, key: &GroupKey) -> Option<String> {
|
||||
self.list
|
||||
.entries()
|
||||
.iter()
|
||||
.find(|entry| entry.id == *key.id() && entry.relay == *key.relay())
|
||||
.and_then(|entry| entry.name.clone())
|
||||
}
|
||||
|
||||
fn upsert_entry(&mut self, key: &GroupKey, name: Option<String>) {
|
||||
let name = name.or_else(|| self.entry_name(key));
|
||||
|
||||
self.list.upsert(GroupListEntry {
|
||||
id: key.id().clone(),
|
||||
relay: key.relay().clone(),
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
fn refresh_entry(&mut self, group: Entity<Group>, cx: &mut Context<Self>) {
|
||||
let (key, Some(name)) = ({
|
||||
let group = group.read(cx);
|
||||
|
||||
(
|
||||
group.key().clone(),
|
||||
group
|
||||
.metadata()
|
||||
.and_then(|metadata| metadata.name().map(str::to_owned)),
|
||||
)
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if self.entry_name(&key).as_deref() == Some(name.as_str()) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.upsert_entry(&key, Some(name));
|
||||
self.publish_list(cx);
|
||||
}
|
||||
|
||||
fn publish_list(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
|
||||
let builder = self.list.to_builder();
|
||||
|
||||
self.tasks.push(cx.spawn(async move |_this, _cx| {
|
||||
let event = match builder.finalize_async(&signer).await {
|
||||
Ok(event) => event,
|
||||
Err(error) => {
|
||||
log::warn!("nip29: signing the group list failed: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match client.send_event(&event).to_nip65().await {
|
||||
Ok(output) if output.failed.is_empty() => {}
|
||||
Ok(output) => log::warn!(
|
||||
"nip29: {} relay(s) rejected the group list",
|
||||
output.failed.len()
|
||||
),
|
||||
Err(error) => log::warn!("nip29: publishing the group list failed: {error}"),
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
fn reload(&mut self, key: GroupKey, event: Option<Event>, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
let Some(group) = self.group(&key, cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(me) = nostr.read(cx).current_user() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let logged = key.clone();
|
||||
let load = group.read(cx).load_envelopes(cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |_this, cx| {
|
||||
if let Some(event) = event
|
||||
&& let Err(error) = group
|
||||
.read_with(cx, |group, cx| group.save_envelope(event, cx))
|
||||
.await
|
||||
{
|
||||
log::warn!("nip29: caching failed: {error}");
|
||||
return;
|
||||
}
|
||||
|
||||
match load.await {
|
||||
Ok(events) => {
|
||||
group.update(cx, |group, cx| {
|
||||
group.hydrate(events, me, cx);
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!("nip29: reloading {} failed: {error}", logged.id());
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
fn forget_event(&mut self, group: Entity<Group>, event: Event, cx: &mut Context<Self>) {
|
||||
let Some(target) = event.tags.event_ids().next() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let key = group.read(cx).key().clone();
|
||||
let cache_tag = key.cache_tag();
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let purge = cx.background_spawn(async move {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(target.to_hex())
|
||||
.custom_tag(SingleLetterTag::LOWERCASE_R, cache_tag);
|
||||
|
||||
client.database().delete(filter).await
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if let Err(error) = purge.await {
|
||||
log::warn!("nip29: dropping the deleted envelope failed: {error}");
|
||||
}
|
||||
this.update(cx, |this, cx| {
|
||||
group.update(cx, |group, cx| {
|
||||
group.forget(target, cx);
|
||||
});
|
||||
this.reload(key, Some(event), cx);
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
fn handle_event(&mut self, id: SubscriptionId, event: Event, cx: &mut Context<Self>) {
|
||||
if id == list_subscription_id() {
|
||||
match GroupList::parse(&event) {
|
||||
Ok(list) => self.merge(list, cx),
|
||||
Err(error) => log::warn!("nip29: ignoring a malformed group list: {error}"),
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if id == fork_subscription_id() {
|
||||
self.absorb_fork_list(&event, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
if self.browse.contains_key(&id) {
|
||||
self.absorb_candidate(&id, &event, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(key) = self.routes.get(&id).cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.reload(key, Some(event), cx);
|
||||
}
|
||||
|
||||
fn absorb_candidate(&mut self, id: &SubscriptionId, event: &Event, cx: &mut Context<Self>) {
|
||||
let Some(relay) = self.browse.get(id).cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(metadata) = GroupMetadata::parse(event) else {
|
||||
log::warn!("nip29: ignoring malformed group metadata from {relay}");
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(group) = metadata.id().cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
cx.emit(GroupsEvent::Browsed(Box::new(GroupCandidate {
|
||||
key: GroupKey::new(relay, group),
|
||||
metadata,
|
||||
})));
|
||||
}
|
||||
|
||||
fn handle_eose(&mut self, id: SubscriptionId, cx: &mut Context<Self>) {
|
||||
let Some(key) = self.routes.get(&id).cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if is_page(&id) {
|
||||
self.routes.remove(&id);
|
||||
|
||||
if let Some(group) = self.group(&key, cx) {
|
||||
group.update(cx, |group, cx| {
|
||||
group.page_finished(cx);
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(group) = self.group(&key, cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
group.update(cx, |group, cx| {
|
||||
group.eose(cx);
|
||||
});
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn handle_closed(&mut self, id: SubscriptionId, reason: String, cx: &mut Context<Self>) {
|
||||
if let Some(relay) = self.browse.get(&id) {
|
||||
log::warn!("nip29: browsing {relay} was refused: {reason}");
|
||||
return;
|
||||
}
|
||||
|
||||
if id == fork_subscription_id() {
|
||||
log::warn!("nip29: the admins' group lists were refused: {reason}");
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(key) = self.routes.get(&id).cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if is_page(&id) {
|
||||
self.routes.remove(&id);
|
||||
log::warn!(
|
||||
"nip29: loading older messages in {} was refused: {reason}",
|
||||
key.id()
|
||||
);
|
||||
|
||||
if let Some(group) = self.group(&key, cx) {
|
||||
group.update(cx, |group, cx| {
|
||||
group.page_finished(cx);
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(group) = self.group(&key, cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
group.update(cx, |group, cx| {
|
||||
group.refuse(reason, cx);
|
||||
});
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn handle_notifications(&mut self, cx: &mut Context<Self>) {
|
||||
self.notification_listener = None;
|
||||
self.signal_consumer = None;
|
||||
|
||||
let nostr = NostrRegistry::global(cx).read(cx);
|
||||
let client = nostr.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();
|
||||
let mut processed = HashSet::new();
|
||||
const MAX_PROCESSED: usize = 10_000;
|
||||
|
||||
while let Some(notification) = notifications.next().await {
|
||||
let ClientNotification::Message { message, .. } = notification else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let signal = match *message {
|
||||
RelayMessage::Event {
|
||||
subscription_id,
|
||||
event,
|
||||
} => {
|
||||
let id = subscription_id.into_owned();
|
||||
|
||||
if !is_own_subscription(&id) {
|
||||
None
|
||||
} else {
|
||||
if processed.len() >= MAX_PROCESSED {
|
||||
processed.clear();
|
||||
}
|
||||
processed
|
||||
.insert(event.id)
|
||||
.then(|| Signal::Event(id, event.into_owned()))
|
||||
}
|
||||
}
|
||||
RelayMessage::EndOfStoredEvents(id) => {
|
||||
let id = id.into_owned();
|
||||
is_own_subscription(&id).then_some(Signal::Eose(id))
|
||||
}
|
||||
RelayMessage::Closed {
|
||||
subscription_id,
|
||||
message,
|
||||
} => {
|
||||
let id = subscription_id.into_owned();
|
||||
is_own_subscription(&id).then_some(Signal::Closed(id, message.into_owned()))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(signal) = signal {
|
||||
tx.send_async(signal).await.ok();
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
self.signal_consumer = Some(cx.spawn(async move |this, cx| {
|
||||
while let Ok(signal) = rx.recv_async().await {
|
||||
this.update(cx, |this, cx| match signal {
|
||||
Signal::Event(id, event) => {
|
||||
this.handle_event(id, event, cx);
|
||||
}
|
||||
Signal::Eose(id) => {
|
||||
this.handle_eose(id, cx);
|
||||
}
|
||||
Signal::Closed(id, reason) => {
|
||||
this.handle_closed(id, reason, cx);
|
||||
}
|
||||
Signal::BrowseSettled(id) => {
|
||||
this.settle_browse(id, cx);
|
||||
}
|
||||
Signal::Forks => {
|
||||
this.sync_forks(true, cx);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,925 @@
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::fmt;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
pub const TIMELINE_WINDOW: usize = 50;
|
||||
|
||||
const PREVIOUS_REFS: usize = 3;
|
||||
const PREVIOUS_REF_LEN: usize = 8;
|
||||
|
||||
pub const RENDER_KINDS: [Kind; 5] = [
|
||||
Kind::TextNote,
|
||||
Kind::ChatMessage,
|
||||
Kind::Thread,
|
||||
Kind::Comment,
|
||||
Kind::Reaction,
|
||||
];
|
||||
|
||||
pub const PIN_LIST: u16 = 39_005;
|
||||
pub const UPDATE_PIN_LIST: u16 = 9_010;
|
||||
|
||||
pub const STATE_KINDS: [Kind; 6] = [
|
||||
Kind::GroupMetadata,
|
||||
Kind::GroupAdmins,
|
||||
Kind::GroupMembers,
|
||||
Kind::GroupRoles,
|
||||
Kind::GroupLivekitParticipants,
|
||||
Kind::Custom(PIN_LIST),
|
||||
];
|
||||
|
||||
pub const MEMBERSHIP_KINDS: [Kind; 2] = [Kind::GroupPutUser, Kind::GroupRemoveUser];
|
||||
|
||||
pub const ACTIVITY_KINDS: [Kind; 4] = [
|
||||
Kind::GroupPutUser,
|
||||
Kind::GroupRemoveUser,
|
||||
Kind::GroupJoinRequest,
|
||||
Kind::GroupLeaveRequest,
|
||||
];
|
||||
|
||||
fn has_tag(tags: &Tags, name: &str) -> bool {
|
||||
tags.iter().any(|tag| tag.kind() == name)
|
||||
}
|
||||
|
||||
fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
|
||||
tags.iter()
|
||||
.find(|tag| tag.kind() == name)
|
||||
.and_then(|tag| tag.content())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct GroupId(String);
|
||||
|
||||
impl GroupId {
|
||||
pub fn new(id: impl Into<String>) -> Result<Self> {
|
||||
let id = id.into();
|
||||
let id = id.trim();
|
||||
|
||||
if id.is_empty() {
|
||||
bail!("group id is empty");
|
||||
}
|
||||
|
||||
Ok(Self(id.to_owned()))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn h_tag(&self) -> Tag {
|
||||
Tag::custom("h", [self.0.clone()])
|
||||
}
|
||||
|
||||
pub fn timeline_filter(&self, limit: usize) -> Filter {
|
||||
Filter::new()
|
||||
.custom_tag(SingleLetterTag::LOWERCASE_H, self.0.clone())
|
||||
.kinds(RENDER_KINDS)
|
||||
.kinds(ACTIVITY_KINDS)
|
||||
.limit(limit)
|
||||
}
|
||||
|
||||
pub fn timeline_page(&self, limit: usize, until: Timestamp) -> Filter {
|
||||
self.timeline_filter(limit).until(until)
|
||||
}
|
||||
|
||||
pub fn state_filters(&self) -> Vec<Filter> {
|
||||
STATE_KINDS
|
||||
.iter()
|
||||
.map(|kind| Filter::new().identifier(self.0.clone()).kind(*kind))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn membership_filter(&self, me: PublicKey) -> Filter {
|
||||
Filter::new()
|
||||
.custom_tag(SingleLetterTag::LOWERCASE_H, self.0.clone())
|
||||
.custom_tag(SingleLetterTag::LOWERCASE_P, me.to_hex())
|
||||
.kinds(MEMBERSHIP_KINDS)
|
||||
}
|
||||
|
||||
pub fn message(
|
||||
&self,
|
||||
content: &str,
|
||||
replies: &[EventId],
|
||||
previous: Option<Tag>,
|
||||
) -> EventBuilder {
|
||||
EventBuilder::new(Kind::ChatMessage, content)
|
||||
.tag(self.h_tag())
|
||||
.tags(replies.iter().copied().map(Tag::event))
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
|
||||
pub fn reaction(&self, target: EventId, emoji: &str, previous: Option<Tag>) -> EventBuilder {
|
||||
EventBuilder::new(Kind::Reaction, emoji)
|
||||
.tag(self.h_tag())
|
||||
.tag(Tag::event(target))
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
|
||||
pub fn join_request(&self, code: Option<&str>, reason: &str) -> EventBuilder {
|
||||
EventBuilder::new(Kind::GroupJoinRequest, reason)
|
||||
.tag(self.h_tag())
|
||||
.tag_maybe(code.map(|code| Tag::custom("code", [code])))
|
||||
}
|
||||
|
||||
pub fn leave_request(&self, reason: &str) -> EventBuilder {
|
||||
EventBuilder::new(Kind::GroupLeaveRequest, reason).tag(self.h_tag())
|
||||
}
|
||||
|
||||
pub fn put_user(
|
||||
&self,
|
||||
public_key: PublicKey,
|
||||
roles: &[String],
|
||||
previous: Option<Tag>,
|
||||
) -> EventBuilder {
|
||||
let mut values = Vec::with_capacity(1 + roles.len());
|
||||
values.push(public_key.to_hex());
|
||||
values.extend(roles.iter().cloned());
|
||||
|
||||
EventBuilder::new(Kind::GroupPutUser, "")
|
||||
.tag(self.h_tag())
|
||||
.tag(Tag::custom("p", values))
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
|
||||
pub fn remove_user(&self, public_key: PublicKey, previous: Option<Tag>) -> EventBuilder {
|
||||
EventBuilder::new(Kind::GroupRemoveUser, "")
|
||||
.tag(self.h_tag())
|
||||
.tag(Tag::public_key(public_key))
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
|
||||
pub fn edit_metadata(&self, metadata: &GroupMetadata, previous: Option<Tag>) -> EventBuilder {
|
||||
EventBuilder::new(Kind::GroupEditMetadata, "")
|
||||
.tags(self.metadata_tags(metadata))
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
|
||||
pub fn create_group(&self, metadata: &GroupMetadata, previous: Option<Tag>) -> EventBuilder {
|
||||
EventBuilder::new(Kind::GroupCreateGroup, "")
|
||||
.tags(self.metadata_tags(metadata))
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
|
||||
fn metadata_tags(&self, metadata: &GroupMetadata) -> Vec<Tag> {
|
||||
let mut tags = vec![self.h_tag()];
|
||||
|
||||
for (name, value) in [
|
||||
("name", &metadata.name),
|
||||
("picture", &metadata.picture),
|
||||
("banner", &metadata.banner),
|
||||
("about", &metadata.about),
|
||||
] {
|
||||
if let Some(value) = value {
|
||||
tags.push(Tag::custom(name, [value.clone()]));
|
||||
}
|
||||
}
|
||||
|
||||
for (name, set) in [
|
||||
("private", metadata.private),
|
||||
("restricted", metadata.restricted),
|
||||
("hidden", metadata.hidden),
|
||||
("closed", metadata.closed),
|
||||
("livekit", metadata.livekit),
|
||||
] {
|
||||
if set {
|
||||
tags.push(Tag::custom(name, Vec::<String>::new()));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(parent) = &metadata.parent {
|
||||
tags.push(Tag::custom("parent", [parent.as_str()]));
|
||||
}
|
||||
|
||||
for child in &metadata.children {
|
||||
tags.push(Tag::custom("child", [child.as_str()]));
|
||||
}
|
||||
|
||||
if let Some(kinds) = &metadata.supported_kinds {
|
||||
tags.push(Tag::custom(
|
||||
"supported_kinds",
|
||||
kinds.iter().map(ToString::to_string),
|
||||
));
|
||||
}
|
||||
|
||||
tags
|
||||
}
|
||||
|
||||
pub fn delete_event(&self, id: EventId, previous: Option<Tag>) -> EventBuilder {
|
||||
EventBuilder::new(Kind::GroupDeleteEvent, "")
|
||||
.tag(self.h_tag())
|
||||
.tag(Tag::event(id))
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
|
||||
pub fn create_invite(&self, code: &str, previous: Option<Tag>) -> EventBuilder {
|
||||
EventBuilder::new(Kind::GroupCreateInvite, "")
|
||||
.tag(self.h_tag())
|
||||
.tag(Tag::custom("code", [code]))
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
|
||||
pub fn update_pin_list(&self, pins: &[Pin], previous: Option<Tag>) -> EventBuilder {
|
||||
EventBuilder::new(Kind::Custom(UPDATE_PIN_LIST), "")
|
||||
.tag(self.h_tag())
|
||||
.tags(pins.iter().map(Pin::tag))
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for GroupId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct GroupKey {
|
||||
relay: RelayUrl,
|
||||
id: GroupId,
|
||||
}
|
||||
|
||||
impl GroupKey {
|
||||
pub fn new(relay: RelayUrl, id: GroupId) -> Self {
|
||||
Self { relay, id }
|
||||
}
|
||||
|
||||
pub fn relay(&self) -> &RelayUrl {
|
||||
&self.relay
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &GroupId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn cache_tag(&self) -> String {
|
||||
format!("{}|{}", self.relay.as_str(), self.id)
|
||||
}
|
||||
|
||||
pub fn uniq_id(&self) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
self.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
pub fn livekit_token_url(&self) -> Result<Url> {
|
||||
self.livekit_url(Some(self.id.as_str()))
|
||||
}
|
||||
|
||||
pub fn livekit_probe_url(&self) -> Result<Url> {
|
||||
self.livekit_url(None)
|
||||
}
|
||||
|
||||
fn livekit_url(&self, group: Option<&str>) -> Result<Url> {
|
||||
let mut url: Url = self.relay.clone().into();
|
||||
let scheme = if self.relay.scheme().is_secure() {
|
||||
"https"
|
||||
} else {
|
||||
"http"
|
||||
};
|
||||
|
||||
url.set_path("/");
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
url.set_scheme(scheme)
|
||||
.map_err(|_| anyhow!("{scheme} is not available for {}", self.relay))?;
|
||||
|
||||
{
|
||||
let mut segments = url
|
||||
.path_segments_mut()
|
||||
.map_err(|_| anyhow!("{} cannot carry a path", self.relay))?;
|
||||
|
||||
segments
|
||||
.pop_if_empty()
|
||||
.push(".well-known")
|
||||
.push("nip29")
|
||||
.push("livekit");
|
||||
|
||||
if let Some(group) = group {
|
||||
segments.push(group);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GroupReference {
|
||||
id: GroupId,
|
||||
relay: RelayUrl,
|
||||
invite: Option<String>,
|
||||
}
|
||||
|
||||
impl GroupReference {
|
||||
pub fn parse(input: &str) -> Result<Self> {
|
||||
let input = input.trim();
|
||||
|
||||
let (address, query) = match input.split_once('?') {
|
||||
Some((address, query)) => (address, Some(query)),
|
||||
None => (input, None),
|
||||
};
|
||||
|
||||
let coordinate = Nip19Coordinate::from_bech32(address)?;
|
||||
|
||||
if coordinate.kind != Kind::GroupMetadata {
|
||||
bail!("not a group reference: kind {}", coordinate.kind);
|
||||
}
|
||||
|
||||
let relay = coordinate
|
||||
.relays
|
||||
.first()
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("group reference has no relay hint"))?;
|
||||
|
||||
let id = GroupId::new(coordinate.identifier.as_str())?;
|
||||
|
||||
let invite = query
|
||||
.and_then(|query| {
|
||||
query
|
||||
.split('&')
|
||||
.find_map(|pair| pair.strip_prefix("invite="))
|
||||
})
|
||||
.map(str::to_owned);
|
||||
|
||||
Ok(Self { id, relay, invite })
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &GroupId {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn relay(&self) -> &RelayUrl {
|
||||
&self.relay
|
||||
}
|
||||
|
||||
pub fn invite(&self) -> Option<&str> {
|
||||
self.invite.as_deref()
|
||||
}
|
||||
|
||||
pub fn key(&self) -> GroupKey {
|
||||
GroupKey::new(self.relay.clone(), self.id.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for GroupReference {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(input: &str) -> Result<Self> {
|
||||
Self::parse(input)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GroupMetadata {
|
||||
pub name: Option<String>,
|
||||
pub picture: Option<String>,
|
||||
pub banner: Option<String>,
|
||||
pub about: Option<String>,
|
||||
pub private: bool,
|
||||
pub restricted: bool,
|
||||
pub hidden: bool,
|
||||
pub closed: bool,
|
||||
pub livekit: bool,
|
||||
pub parent: Option<GroupId>,
|
||||
pub children: Vec<GroupId>,
|
||||
pub supported_kinds: Option<Vec<Kind>>,
|
||||
id: Option<GroupId>,
|
||||
author: Option<PublicKey>,
|
||||
}
|
||||
|
||||
impl GroupMetadata {
|
||||
pub fn parse(event: &Event) -> Result<Self> {
|
||||
if event.kind != Kind::GroupMetadata {
|
||||
bail!("not a group metadata event: kind {}", event.kind);
|
||||
}
|
||||
|
||||
let identifier = event
|
||||
.tags
|
||||
.identifier()
|
||||
.ok_or_else(|| anyhow!("group metadata has no d tag"))?;
|
||||
|
||||
let mut supported_kinds: Option<Vec<Kind>> = None;
|
||||
|
||||
for tag in event
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|tag| tag.kind() == "supported_kinds")
|
||||
{
|
||||
let kinds = supported_kinds.get_or_insert_with(Vec::new);
|
||||
|
||||
for value in tag.as_slice().iter().skip(1) {
|
||||
kinds.push(Kind::from_str(value)?);
|
||||
}
|
||||
}
|
||||
|
||||
let parent = match tag_value(&event.tags, "parent") {
|
||||
Some(parent) => Some(GroupId::new(parent)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let mut children = Vec::new();
|
||||
|
||||
for tag in event.tags.iter().filter(|tag| tag.kind() == "child") {
|
||||
if let Some(child) = tag.content() {
|
||||
children.push(GroupId::new(child)?);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
name: tag_value(&event.tags, "name").map(str::to_owned),
|
||||
picture: tag_value(&event.tags, "picture").map(str::to_owned),
|
||||
banner: tag_value(&event.tags, "banner").map(str::to_owned),
|
||||
about: tag_value(&event.tags, "about").map(str::to_owned),
|
||||
private: has_tag(&event.tags, "private"),
|
||||
restricted: has_tag(&event.tags, "restricted"),
|
||||
hidden: has_tag(&event.tags, "hidden"),
|
||||
closed: has_tag(&event.tags, "closed"),
|
||||
livekit: has_tag(&event.tags, "livekit"),
|
||||
parent,
|
||||
children,
|
||||
supported_kinds,
|
||||
id: Some(GroupId::new(identifier)?),
|
||||
author: Some(event.pubkey),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn id(&self) -> Option<&GroupId> {
|
||||
self.id.as_ref()
|
||||
}
|
||||
|
||||
pub fn name(&self) -> Option<&str> {
|
||||
self.name.as_deref()
|
||||
}
|
||||
|
||||
pub fn supports(&self, kind: Kind) -> bool {
|
||||
match &self.supported_kinds {
|
||||
None => true,
|
||||
Some(kinds) => kinds.contains(&kind),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn naddr(&self, relay: &RelayUrl, invite: Option<&str>) -> Result<String> {
|
||||
let id = self.id.as_ref().ok_or_else(|| anyhow!("no id"))?;
|
||||
let author = self.author.ok_or_else(|| anyhow!("no author"))?;
|
||||
|
||||
let coordinate = Coordinate::new(Kind::GroupMetadata, author).identifier(id.as_str());
|
||||
let address = Nip19Coordinate::new(coordinate, [relay.clone()]).to_bech32()?;
|
||||
|
||||
Ok(match invite {
|
||||
Some(invite) => format!("{address}?invite={invite}"),
|
||||
None => address,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GroupCandidate {
|
||||
pub key: GroupKey,
|
||||
pub metadata: GroupMetadata,
|
||||
}
|
||||
|
||||
impl GroupCandidate {
|
||||
pub fn display_name(&self) -> &str {
|
||||
self.metadata
|
||||
.name()
|
||||
.unwrap_or_else(|| self.key.id().as_str())
|
||||
}
|
||||
|
||||
pub fn display_image(&self) -> Option<&str> {
|
||||
self.metadata.picture.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GroupAdmins(Vec<(PublicKey, Vec<String>)>);
|
||||
|
||||
impl GroupAdmins {
|
||||
pub fn parse(event: &Event) -> Result<Self> {
|
||||
if event.kind != Kind::GroupAdmins {
|
||||
bail!("not a group admins event: kind {}", event.kind);
|
||||
}
|
||||
|
||||
let mut admins = Vec::new();
|
||||
|
||||
for tag in event.tags.iter().filter(|tag| tag.kind() == "p") {
|
||||
let mut fields = tag.as_slice().iter().skip(1);
|
||||
|
||||
let public_key = fields
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("admin tag has no public key"))?;
|
||||
let public_key = PublicKey::from_hex(public_key)?;
|
||||
|
||||
admins.push((public_key, fields.cloned().collect()));
|
||||
}
|
||||
|
||||
Ok(Self(admins))
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> &[(PublicKey, Vec<String>)] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn contains(&self, public_key: &PublicKey) -> bool {
|
||||
self.0.iter().any(|(key, _)| key == public_key)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GroupMembers(Vec<PublicKey>);
|
||||
|
||||
impl GroupMembers {
|
||||
pub fn parse(event: &Event) -> Result<Self> {
|
||||
if event.kind != Kind::GroupMembers {
|
||||
bail!("not a group members event: kind {}", event.kind);
|
||||
}
|
||||
|
||||
Ok(Self(event.tags.public_keys().collect()))
|
||||
}
|
||||
|
||||
pub fn contains(&self, public_key: &PublicKey) -> bool {
|
||||
self.0.contains(public_key)
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = &PublicKey> {
|
||||
self.0.iter()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Role {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GroupRoles(Vec<Role>);
|
||||
|
||||
impl GroupRoles {
|
||||
pub fn parse(event: &Event) -> Result<Self> {
|
||||
if event.kind != Kind::GroupRoles {
|
||||
bail!("not a group roles event: kind {}", event.kind);
|
||||
}
|
||||
|
||||
let mut roles = Vec::new();
|
||||
|
||||
for tag in event.tags.iter().filter(|tag| tag.kind() == "role") {
|
||||
let mut fields = tag.as_slice().iter().skip(1);
|
||||
let name = fields
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("role tag has no name"))?;
|
||||
|
||||
roles.push(Role {
|
||||
name: name.clone(),
|
||||
description: fields.next().cloned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self(roles))
|
||||
}
|
||||
|
||||
pub fn roles(&self) -> &[Role] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GroupParticipants(Vec<PublicKey>);
|
||||
|
||||
impl GroupParticipants {
|
||||
pub fn parse(event: &Event) -> Result<Self> {
|
||||
if event.kind != Kind::GroupLivekitParticipants {
|
||||
bail!("not a group participants event: kind {}", event.kind);
|
||||
}
|
||||
|
||||
let mut participants = Vec::new();
|
||||
|
||||
for tag in event.tags.iter().filter(|tag| tag.kind() == "participant") {
|
||||
let Some(public_key) = tag.content() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
participants.push(PublicKey::parse(public_key)?);
|
||||
}
|
||||
|
||||
Ok(Self(participants))
|
||||
}
|
||||
|
||||
pub fn contains(&self, public_key: &PublicKey) -> bool {
|
||||
self.0.contains(public_key)
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = &PublicKey> {
|
||||
self.0.iter()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Pin {
|
||||
Event(EventId),
|
||||
Address(Coordinate),
|
||||
}
|
||||
|
||||
impl Pin {
|
||||
fn tag(&self) -> Tag {
|
||||
match self {
|
||||
Pin::Event(id) => Tag::event(*id),
|
||||
Pin::Address(coordinate) => Tag::coordinate(coordinate.clone(), None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GroupPins(Vec<Pin>);
|
||||
|
||||
impl GroupPins {
|
||||
pub fn parse(event: &Event) -> Result<Self> {
|
||||
if event.kind != Kind::Custom(PIN_LIST) {
|
||||
bail!("not a pinned list event: kind {}", event.kind);
|
||||
}
|
||||
|
||||
let mut pins = Vec::new();
|
||||
|
||||
for tag in event.tags.iter() {
|
||||
match tag.kind() {
|
||||
"e" => {
|
||||
let Some(id) = tag.content() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
pins.push(Pin::Event(EventId::from_hex(id)?));
|
||||
}
|
||||
"a" => {
|
||||
let Some(address) = tag.content() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
pins.push(Pin::Address(Coordinate::from_str(address)?));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self(pins))
|
||||
}
|
||||
|
||||
pub fn pins(&self) -> &[Pin] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LivekitSession {
|
||||
pub token: String,
|
||||
pub server_url: String,
|
||||
}
|
||||
|
||||
impl LivekitSession {
|
||||
pub fn parse(body: &[u8]) -> Result<Self> {
|
||||
let value: serde_json::Value = serde_json::from_slice(body)?;
|
||||
|
||||
let token = value
|
||||
.get("token")
|
||||
.or_else(|| value.get("jwt"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| anyhow!("the AV response carries no token"))?;
|
||||
|
||||
let server_url = value
|
||||
.get("url")
|
||||
.or_else(|| value.get("server_url"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| anyhow!("the AV response carries no server url"))?;
|
||||
|
||||
Ok(Self {
|
||||
token: token.to_owned(),
|
||||
server_url: server_url.to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GroupListEntry {
|
||||
pub id: GroupId,
|
||||
pub relay: RelayUrl,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GroupList(Vec<GroupListEntry>);
|
||||
|
||||
impl GroupList {
|
||||
pub fn parse(event: &Event) -> Result<Self> {
|
||||
if event.kind != Kind::SimpleGroups {
|
||||
bail!("not a group list event: kind {}", event.kind);
|
||||
}
|
||||
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for tag in event.tags.iter().filter(|tag| tag.kind() == "group") {
|
||||
let fields = tag.as_slice();
|
||||
|
||||
let (Some(id), Some(relay)) = (fields.get(1), fields.get(2)) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Ok(id) = GroupId::new(id.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(relay) = RelayUrl::parse(relay.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
entries.push(GroupListEntry {
|
||||
id,
|
||||
relay,
|
||||
name: fields.get(3).cloned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self(entries))
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> &[GroupListEntry] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn upsert(&mut self, entry: GroupListEntry) {
|
||||
match self
|
||||
.0
|
||||
.iter_mut()
|
||||
.find(|existing| existing.id == entry.id && existing.relay == entry.relay)
|
||||
{
|
||||
Some(existing) => *existing = entry,
|
||||
None => self.0.push(entry),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, key: &GroupKey) {
|
||||
self.0
|
||||
.retain(|entry| entry.id != *key.id() || entry.relay != *key.relay());
|
||||
}
|
||||
|
||||
pub fn to_builder(&self) -> EventBuilder {
|
||||
let mut tags: Vec<Tag> = Vec::with_capacity(self.0.len() * 2);
|
||||
|
||||
for entry in &self.0 {
|
||||
let mut values = vec![
|
||||
entry.id.as_str().to_owned(),
|
||||
entry.relay.as_str().to_owned(),
|
||||
];
|
||||
|
||||
if let Some(name) = &entry.name {
|
||||
values.push(name.clone());
|
||||
}
|
||||
|
||||
tags.push(Tag::custom("group", values));
|
||||
tags.push(Tag::custom("r", [entry.relay.as_str()]));
|
||||
}
|
||||
|
||||
EventBuilder::new(Kind::SimpleGroups, "").tags(tags)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Membership {
|
||||
Unknown,
|
||||
Pending { since: Timestamp },
|
||||
Member,
|
||||
Refused { reason: Option<String> },
|
||||
Removed,
|
||||
}
|
||||
|
||||
impl Membership {
|
||||
pub fn from_events(events: &[Event], me: PublicKey) -> Self {
|
||||
let mut latest: Option<&Event> = None;
|
||||
|
||||
for event in events {
|
||||
if !MEMBERSHIP_KINDS.contains(&event.kind) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !event.tags.public_keys().any(|key| key == me) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let newer = match latest {
|
||||
None => true,
|
||||
Some(current) => {
|
||||
event.created_at > current.created_at
|
||||
|| (event.created_at == current.created_at
|
||||
&& current.kind == Kind::GroupPutUser
|
||||
&& event.kind == Kind::GroupRemoveUser)
|
||||
}
|
||||
};
|
||||
|
||||
if newer {
|
||||
latest = Some(event);
|
||||
}
|
||||
}
|
||||
|
||||
match latest {
|
||||
Some(event) if event.kind == Kind::GroupRemoveUser => Membership::Removed,
|
||||
Some(_) => Membership::Member,
|
||||
None => Membership::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Activity {
|
||||
pub id: EventId,
|
||||
pub kind: Kind,
|
||||
pub author: PublicKey,
|
||||
pub target: Option<PublicKey>,
|
||||
pub roles: Vec<String>,
|
||||
pub created_at: Timestamp,
|
||||
}
|
||||
|
||||
impl Activity {
|
||||
pub fn parse(event: &Event) -> Result<Self> {
|
||||
if !ACTIVITY_KINDS.contains(&event.kind) {
|
||||
bail!("not a group activity event: kind {}", event.kind);
|
||||
}
|
||||
|
||||
let mut fields = event
|
||||
.tags
|
||||
.iter()
|
||||
.find(|tag| tag.kind() == "p")
|
||||
.map(|tag| tag.as_slice().iter().skip(1))
|
||||
.into_iter()
|
||||
.flatten();
|
||||
|
||||
let target = match fields.next() {
|
||||
Some(value) => Some(PublicKey::from_hex(value)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
if target.is_none() && MEMBERSHIP_KINDS.contains(&event.kind) {
|
||||
bail!("a membership event has no p tag");
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
id: event.id,
|
||||
kind: event.kind,
|
||||
author: event.pubkey,
|
||||
target,
|
||||
roles: fields.cloned().collect(),
|
||||
created_at: event.created_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn subject(&self) -> PublicKey {
|
||||
match self.kind {
|
||||
Kind::GroupPutUser | Kind::GroupRemoveUser => self.target.unwrap_or(self.author),
|
||||
_ => self.author,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TimelineWindow(Vec<Event>);
|
||||
|
||||
impl TimelineWindow {
|
||||
pub fn new() -> Self {
|
||||
Self(Vec::new())
|
||||
}
|
||||
|
||||
pub fn push(&mut self, event: Event) {
|
||||
self.0.retain(|existing| existing.id != event.id);
|
||||
let position = self.0.partition_point(|e| e.created_at > event.created_at);
|
||||
|
||||
self.0.insert(position, event);
|
||||
self.0.truncate(TIMELINE_WINDOW);
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, id: EventId) {
|
||||
self.0.retain(|event| event.id != id);
|
||||
}
|
||||
|
||||
pub fn previous_tag(&self, me: PublicKey) -> Option<Tag> {
|
||||
let references: Vec<String> = self
|
||||
.0
|
||||
.iter()
|
||||
.filter(|event| event.pubkey != me)
|
||||
.take(PREVIOUS_REFS)
|
||||
.map(|event| event.id.to_hex().chars().take(PREVIOUS_REF_LEN).collect())
|
||||
.collect();
|
||||
|
||||
if references.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Tag::custom("previous", references))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,23 @@
|
||||
[package]
|
||||
name = "community_ui"
|
||||
name = "nip29_ui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
community = { path = "../community" }
|
||||
state = { path = "../state" }
|
||||
ui = { path = "../ui" }
|
||||
theme = { path = "../theme" }
|
||||
chat = { path = "../chat" }
|
||||
chat_ui = { path = "../chat_ui" }
|
||||
common = { path = "../common" }
|
||||
nip29 = { path = "../nip29" }
|
||||
person = { path = "../person" }
|
||||
settings = { path = "../settings" }
|
||||
state = { path = "../state" }
|
||||
theme = { path = "../theme" }
|
||||
ui = { path = "../ui" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
serde.workspace = true
|
||||
smallvec.workspace = true
|
||||
@@ -0,0 +1,268 @@
|
||||
use gpui::{
|
||||
AnyElement, App, IntoElement, ParentElement, SharedString, Styled, WeakEntity, Window, div, px,
|
||||
};
|
||||
use nip29::{Group, GroupMetadata};
|
||||
use nostr_sdk::prelude::PublicKey;
|
||||
use person::PersonRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::{Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||
|
||||
pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
|
||||
window.open_dialog(cx, move |this, _window, cx| {
|
||||
this.width(px(460.))
|
||||
.max_h(px(600.))
|
||||
.show_close(true)
|
||||
.title("Group details")
|
||||
.child(body(&group, cx))
|
||||
});
|
||||
}
|
||||
|
||||
fn body(group: &WeakEntity<Group>, cx: &App) -> AnyElement {
|
||||
let Some(entity) = group.upgrade() else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
let group = entity.read(cx);
|
||||
let mut body = v_flex().gap_3().text_sm().child(header(group, cx));
|
||||
|
||||
if let Some(metadata) = group.metadata() {
|
||||
if let Some(about) = metadata.about.as_ref() {
|
||||
body = body.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(SharedString::from(about.clone())),
|
||||
);
|
||||
}
|
||||
|
||||
let flags = flags(metadata);
|
||||
|
||||
if !flags.is_empty() {
|
||||
body = body.child(section(
|
||||
"Flags",
|
||||
wrap(flags.into_iter().map(|f| chip(f, cx))),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(parent) = metadata.parent.as_ref() {
|
||||
body = body.child(section(
|
||||
"Parent",
|
||||
div()
|
||||
.child(SharedString::from(parent.as_str().to_owned()))
|
||||
.into_any_element(),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if !metadata.children.is_empty() {
|
||||
body = body.child(section(
|
||||
"Children",
|
||||
wrap(
|
||||
metadata
|
||||
.children
|
||||
.iter()
|
||||
.map(|child| chip(child.as_str().to_owned(), cx)),
|
||||
),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(kinds) = metadata.supported_kinds.as_ref() {
|
||||
let items: Vec<AnyElement> = kinds
|
||||
.iter()
|
||||
.map(|kind| chip(kind.to_string(), cx))
|
||||
.collect();
|
||||
|
||||
body = body.child(section(
|
||||
"Supported kinds",
|
||||
if items.is_empty() {
|
||||
div()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child("None")
|
||||
.into_any_element()
|
||||
} else {
|
||||
wrap(items)
|
||||
},
|
||||
cx,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let admins = group.admins().entries();
|
||||
|
||||
if !admins.is_empty() {
|
||||
body = body.child(section(
|
||||
"Admins",
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.children(admins.iter().map(|(key, roles)| admin(key, roles, cx)))
|
||||
.into_any_element(),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
let roles = group.roles().roles();
|
||||
|
||||
if !roles.is_empty() {
|
||||
body = body.child(section(
|
||||
"Roles",
|
||||
wrap(roles.iter().map(|role| chip(role.name.clone(), cx))),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
let members: Vec<&PublicKey> = group.members().iter().collect();
|
||||
|
||||
if !members.is_empty() {
|
||||
body = body.child(section(
|
||||
"Members",
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.children(members.into_iter().map(|key| person(key, cx)))
|
||||
.into_any_element(),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
let pins = group.pins().pins();
|
||||
|
||||
if !pins.is_empty() {
|
||||
body = body.child(section(
|
||||
"Pinned",
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.children(
|
||||
pins.iter()
|
||||
.map(|pin| {
|
||||
div()
|
||||
.truncate()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(crate::pin_label(group, pin))
|
||||
.into_any_element()
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.into_any_element(),
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
body.into_any_element()
|
||||
}
|
||||
|
||||
fn header(group: &Group, cx: &App) -> AnyElement {
|
||||
let key = group.key();
|
||||
|
||||
h_flex()
|
||||
.gap_3()
|
||||
.items_center()
|
||||
.child(
|
||||
Avatar::from_source(group.display_image())
|
||||
.seed(key.cache_tag())
|
||||
.large(),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.min_w_0()
|
||||
.child(
|
||||
div()
|
||||
.text_base()
|
||||
.font_semibold()
|
||||
.truncate()
|
||||
.child(group.display_name()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.truncate()
|
||||
.child(SharedString::from(format!(
|
||||
"{} · {}",
|
||||
key.id().as_str(),
|
||||
key.relay().as_str()
|
||||
))),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn section(label: &'static str, content: AnyElement, cx: &App) -> AnyElement {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_medium()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(label),
|
||||
)
|
||||
.child(content)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn wrap(items: impl IntoIterator<Item = AnyElement>) -> AnyElement {
|
||||
h_flex()
|
||||
.flex_wrap()
|
||||
.gap_1()
|
||||
.children(items)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn admin(public_key: &PublicKey, roles: &[String], cx: &App) -> AnyElement {
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.child(person(public_key, cx))
|
||||
.children(roles.iter().map(|role| chip(role.clone(), cx)))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn person(public_key: &PublicKey, cx: &App) -> AnyElement {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let person = persons.read(cx).get(public_key, cx);
|
||||
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.min_w_0()
|
||||
.child(
|
||||
Avatar::new(person.avatar())
|
||||
.seed(person.avatar_seed())
|
||||
.xsmall(),
|
||||
)
|
||||
.child(div().truncate().child(person.name()))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn chip(label: impl Into<SharedString>, cx: &App) -> AnyElement {
|
||||
div()
|
||||
.px_1p5()
|
||||
.py_0p5()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().element_background)
|
||||
.text_xs()
|
||||
.child(label.into())
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn flags(metadata: &GroupMetadata) -> Vec<&'static str> {
|
||||
let mut flags = Vec::new();
|
||||
|
||||
if metadata.private {
|
||||
flags.push("Private");
|
||||
}
|
||||
if metadata.restricted {
|
||||
flags.push("Restricted");
|
||||
}
|
||||
if metadata.hidden {
|
||||
flags.push("Hidden");
|
||||
}
|
||||
if metadata.closed {
|
||||
flags.push("Closed");
|
||||
}
|
||||
if metadata.livekit {
|
||||
flags.push("LiveKit");
|
||||
}
|
||||
|
||||
flags
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
use anyhow::Result;
|
||||
use gpui::{
|
||||
App, AppContext, ClipboardItem, ParentElement, SharedString, Styled, WeakEntity, Window, div,
|
||||
px,
|
||||
};
|
||||
use nip29::Group;
|
||||
use nostr_sdk::prelude::SecretKey;
|
||||
use theme::ActiveTheme;
|
||||
use ui::dialog::DialogButtonProps;
|
||||
use ui::input::InputState;
|
||||
use ui::notification::Notification;
|
||||
use ui::{WindowExtension, v_flex};
|
||||
|
||||
use crate::field;
|
||||
|
||||
const CODE_LEN: usize = 12;
|
||||
|
||||
pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
|
||||
let code = SecretKey::generate().to_secret_hex();
|
||||
let code: String = code.chars().take(CODE_LEN).collect();
|
||||
|
||||
let input = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("Invite code")
|
||||
.default_value(code)
|
||||
});
|
||||
|
||||
window.open_dialog(cx, move |this, _window, cx| {
|
||||
let group = group.clone();
|
||||
let input = input.clone();
|
||||
|
||||
this.width(px(420.))
|
||||
.confirm()
|
||||
.title("Invite link")
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.cancel_text("Cancel")
|
||||
.ok_text("Copy link"),
|
||||
)
|
||||
.child(
|
||||
v_flex().gap_3().child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("Share the code with whoever should be able to join. The relay must accept it."),
|
||||
)
|
||||
.child(field("Code", &input, cx)),
|
||||
)
|
||||
.on_ok(move |_event, window, cx| {
|
||||
let code = input.read(cx).value().trim().to_owned();
|
||||
|
||||
if code.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let link = group
|
||||
.read_with(cx, |group, _cx| {
|
||||
group.metadata().and_then(|metadata| {
|
||||
metadata
|
||||
.naddr(group.key().relay(), Some(&code))
|
||||
.ok()
|
||||
.map(SharedString::from)
|
||||
})
|
||||
})
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let Some(link) = link else {
|
||||
window.push_notification(
|
||||
Notification::error(
|
||||
"The relay doesn't publish group details, so no invite link can be made",
|
||||
),
|
||||
cx,
|
||||
);
|
||||
return false;
|
||||
};
|
||||
|
||||
match group.update(cx, |group, cx| group.create_invite(&code, cx)) {
|
||||
Ok(task) => {
|
||||
window
|
||||
.spawn(cx, async move |cx| -> Result<()> {
|
||||
if let Err(error) = task.await {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(
|
||||
Notification::error(error.to_string()),
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
cx.update(|window, cx| {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(
|
||||
link.to_string(),
|
||||
));
|
||||
window.push_notification(
|
||||
Notification::success("Invite link copied"),
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
Err(error) => log::warn!("nip29: creating an invite failed: {error}"),
|
||||
}
|
||||
|
||||
true
|
||||
})
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,239 @@
|
||||
use chat::Message;
|
||||
use common::TimestampExt;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, InteractiveElement, IntoElement, ObjectFit, ParentElement, SharedString,
|
||||
SharedUri, Styled, StyledImage, WeakEntity, div, img, px,
|
||||
};
|
||||
use nip29::{Activity, Group};
|
||||
use nostr_sdk::prelude::{EventId, Kind};
|
||||
use person::PersonRegistry;
|
||||
use settings::AppSettings;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::menu::{ContextMenu, PopupMenu, PopupMenuItem};
|
||||
use ui::message::MessageRow;
|
||||
use ui::{Icon, IconName, Sizable, StyledExt, h_flex};
|
||||
|
||||
use crate::GroupPanel;
|
||||
|
||||
const REACTIONS: [&str; 6] = ["👍", "❤️", "😂", "🎉", "😮", "😢"];
|
||||
|
||||
pub(crate) struct Actions {
|
||||
pub group: WeakEntity<Group>,
|
||||
pub panel: WeakEntity<GroupPanel>,
|
||||
pub admin: bool,
|
||||
pub pinned: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn render(
|
||||
ix: usize,
|
||||
message: &Message,
|
||||
content: AnyElement,
|
||||
show_author: bool,
|
||||
actions: Actions,
|
||||
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);
|
||||
|
||||
let row = MessageRow::new(ix)
|
||||
.show_author(show_author)
|
||||
.hide_avatar(hide_avatar)
|
||||
.avatar(
|
||||
Avatar::new(author.avatar())
|
||||
.seed(author.avatar_seed())
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
.author(author.name())
|
||||
.timestamp(message.created_at.to_human_time())
|
||||
.child(content)
|
||||
.when(!message.media.is_empty(), |this| {
|
||||
this.child(media(&message.media, cx))
|
||||
})
|
||||
.into_any_element();
|
||||
|
||||
let id = message.id;
|
||||
let admin = actions.admin;
|
||||
let pinned = actions.pinned;
|
||||
let group = actions.group;
|
||||
let panel = actions.panel;
|
||||
|
||||
ContextMenu::new(
|
||||
format!("message-menu-{}", id.to_hex()),
|
||||
row,
|
||||
move |menu, window, cx| {
|
||||
let menu = menu.submenu("React", window, cx, {
|
||||
let group = group.clone();
|
||||
let panel = panel.clone();
|
||||
|
||||
move |menu, _window, _cx| react_menu(menu, group.clone(), panel.clone(), id)
|
||||
});
|
||||
|
||||
if !admin {
|
||||
return menu;
|
||||
}
|
||||
|
||||
menu.separator()
|
||||
.item({
|
||||
let group = group.clone();
|
||||
let panel = panel.clone();
|
||||
|
||||
PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" }).on_click(
|
||||
move |_event, _window, cx| {
|
||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
||||
panel.toggle_pin(group.clone(), id, window, cx);
|
||||
}) {
|
||||
log::warn!("nip29: pinning a message failed: {error}");
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
.item({
|
||||
let group = group.clone();
|
||||
let panel = panel.clone();
|
||||
|
||||
PopupMenuItem::new("Delete message").on_click(move |_event, _window, cx| {
|
||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
||||
panel.delete_message(group.clone(), id, window, cx);
|
||||
}) {
|
||||
log::warn!("nip29: deleting a message failed: {error}");
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn react_menu(
|
||||
menu: PopupMenu,
|
||||
group: WeakEntity<Group>,
|
||||
panel: WeakEntity<GroupPanel>,
|
||||
id: EventId,
|
||||
) -> PopupMenu {
|
||||
REACTIONS.iter().fold(menu, |menu, emoji| {
|
||||
let emoji = *emoji;
|
||||
let group = group.clone();
|
||||
let panel = panel.clone();
|
||||
|
||||
menu.item(
|
||||
PopupMenuItem::new(emoji).on_click(move |_event, _window, cx| {
|
||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
||||
panel.react(group.clone(), id, emoji.to_owned(), window, cx);
|
||||
}) {
|
||||
log::warn!("nip29: sending a reaction failed: {error}");
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn render_activity(ix: usize, activity: &Activity, cx: &App) -> AnyElement {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let subject = persons.read(cx).get(&activity.subject(), cx);
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.py_1()
|
||||
.px_3()
|
||||
.hover(|this| this.bg(cx.theme().surface_background))
|
||||
.child(badge(activity, cx))
|
||||
.when(!hide_avatar, |this| {
|
||||
this.child(
|
||||
Avatar::new(subject.avatar())
|
||||
.seed(subject.avatar_seed())
|
||||
.small()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.child(subject.name()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.truncate()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(SharedString::from(action(activity))),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.ml_1()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(activity.created_at.to_ago()),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn badge(activity: &Activity, cx: &App) -> AnyElement {
|
||||
let (icon, color) = match activity.kind {
|
||||
Kind::GroupJoinRequest => (IconName::Login, cx.theme().icon_accent),
|
||||
Kind::GroupPutUser => (IconName::Login, cx.theme().icon_accent),
|
||||
Kind::GroupLeaveRequest => (IconName::Logout, cx.theme().text_danger),
|
||||
Kind::GroupRemoveUser => (IconName::Ban, cx.theme().text_danger),
|
||||
_ => (IconName::Info, cx.theme().icon_muted),
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.justify_center()
|
||||
.size_8()
|
||||
.child(Icon::new(icon).small().text_color(color))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn action(activity: &Activity) -> String {
|
||||
match activity.kind {
|
||||
Kind::GroupJoinRequest => "joined the group".to_owned(),
|
||||
Kind::GroupLeaveRequest => "left the group".to_owned(),
|
||||
Kind::GroupRemoveUser => "was removed from the group".to_owned(),
|
||||
Kind::GroupPutUser => format!("is now {}", activity.roles.join(", ")),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn media(media: &[SharedUri], cx: &App) -> AnyElement {
|
||||
if let [only] = media {
|
||||
return div()
|
||||
.child(
|
||||
img(only.clone())
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_variant)
|
||||
.h(px(250.))
|
||||
.object_fit(ObjectFit::Cover)
|
||||
.rounded(cx.theme().radius),
|
||||
)
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
div()
|
||||
.w_full()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.flex_row()
|
||||
.flex_wrap()
|
||||
.gap_2()
|
||||
.children(media.iter().enumerate().map(|(ix, item)| {
|
||||
div().id(format!("media-{ix}")).child(
|
||||
img(item.clone())
|
||||
.h_32()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_variant)
|
||||
.rounded(cx.theme().radius),
|
||||
)
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
use gpui::{App, AppContext, Entity, ParentElement, Styled, WeakEntity, Window, px};
|
||||
use nip29::{Group, GroupMetadata};
|
||||
use ui::dialog::DialogButtonProps;
|
||||
use ui::input::InputState;
|
||||
use ui::switch::Switch;
|
||||
use ui::{WindowExtension, v_flex};
|
||||
|
||||
use crate::field;
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Flags {
|
||||
private: bool,
|
||||
restricted: bool,
|
||||
hidden: bool,
|
||||
closed: bool,
|
||||
livekit: bool,
|
||||
}
|
||||
|
||||
impl Flags {
|
||||
fn of(metadata: &GroupMetadata) -> Self {
|
||||
Self {
|
||||
private: metadata.private,
|
||||
restricted: metadata.restricted,
|
||||
hidden: metadata.hidden,
|
||||
closed: metadata.closed,
|
||||
livekit: metadata.livekit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
|
||||
let base = group
|
||||
.read_with(cx, |group, _cx| group.metadata().cloned())
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
|
||||
let name = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("Group name")
|
||||
.default_value(base.name.clone().unwrap_or_default())
|
||||
});
|
||||
|
||||
let about = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("About")
|
||||
.default_value(base.about.clone().unwrap_or_default())
|
||||
});
|
||||
|
||||
let picture = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("https://example.com/picture.png")
|
||||
.default_value(base.picture.clone().unwrap_or_default())
|
||||
});
|
||||
|
||||
let flags = cx.new(|_cx| Flags::of(&base));
|
||||
|
||||
window.open_dialog(cx, move |this, _window, cx| {
|
||||
let current = *flags.read(cx);
|
||||
|
||||
this.width(px(420.))
|
||||
.confirm()
|
||||
.title("Edit group")
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.cancel_text("Cancel")
|
||||
.ok_text("Save"),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(field("Name", &name, cx))
|
||||
.child(field("About", &about, cx))
|
||||
.child(field("Picture", &picture, cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Switch::new("private")
|
||||
.label("Private")
|
||||
.description("Only members can read messages.")
|
||||
.checked(current.private)
|
||||
.on_click(toggle(&flags, |flags| &mut flags.private)),
|
||||
)
|
||||
.child(
|
||||
Switch::new("restricted")
|
||||
.label("Restricted")
|
||||
.description("Only members can send messages.")
|
||||
.checked(current.restricted)
|
||||
.on_click(toggle(&flags, |flags| &mut flags.restricted)),
|
||||
)
|
||||
.child(
|
||||
Switch::new("hidden")
|
||||
.label("Hidden")
|
||||
.description("Hide the group from non-members.")
|
||||
.checked(current.hidden)
|
||||
.on_click(toggle(&flags, |flags| &mut flags.hidden)),
|
||||
)
|
||||
.child(
|
||||
Switch::new("closed")
|
||||
.label("Closed")
|
||||
.description("Ignore join requests.")
|
||||
.checked(current.closed)
|
||||
.on_click(toggle(&flags, |flags| &mut flags.closed)),
|
||||
)
|
||||
.child(
|
||||
Switch::new("livekit")
|
||||
.label("LiveKit")
|
||||
.description("Announce audio and video support.")
|
||||
.checked(current.livekit)
|
||||
.on_click(toggle(&flags, |flags| &mut flags.livekit)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.on_ok({
|
||||
let group = group.clone();
|
||||
let name = name.clone();
|
||||
let about = about.clone();
|
||||
let picture = picture.clone();
|
||||
let flags = flags.clone();
|
||||
|
||||
move |_event, window, cx| {
|
||||
let mut next = group
|
||||
.read_with(cx, |group, _cx| group.metadata().cloned())
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
|
||||
next.name = value(&name, cx);
|
||||
next.about = value(&about, cx);
|
||||
next.picture = value(&picture, cx);
|
||||
|
||||
let current = flags.read(cx);
|
||||
next.private = current.private;
|
||||
next.restricted = current.restricted;
|
||||
next.hidden = current.hidden;
|
||||
next.closed = current.closed;
|
||||
next.livekit = current.livekit;
|
||||
|
||||
match group.update(cx, |group, cx| group.edit_metadata(&next, cx)) {
|
||||
Ok(task) => crate::report(window, cx, task),
|
||||
Err(error) => log::warn!("nip29: editing the group failed: {error}"),
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn toggle(
|
||||
flags: &Entity<Flags>,
|
||||
select: fn(&mut Flags) -> &mut bool,
|
||||
) -> impl Fn(&bool, &mut Window, &mut App) + 'static {
|
||||
let flags = flags.clone();
|
||||
|
||||
move |next, _window, cx| {
|
||||
flags.update(cx, |flags, cx| {
|
||||
*select(flags) = *next;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn value(input: &Entity<InputState>, cx: &App) -> Option<String> {
|
||||
let value = input.read(cx).value().trim().to_owned();
|
||||
|
||||
(!value.is_empty()).then_some(value)
|
||||
}
|
||||
@@ -46,11 +46,8 @@ 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 {
|
||||
@@ -133,10 +130,6 @@ 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 {
|
||||
@@ -149,7 +142,6 @@ impl Default for Settings {
|
||||
nip4e: false,
|
||||
trusted_relays: vec![],
|
||||
file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(),
|
||||
recent_communities: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -332,14 +324,4 @@ 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ impl ThemeColors {
|
||||
window_border: hsl(240.0, 5.9, 78.0),
|
||||
|
||||
border: neutral().light().step_6(),
|
||||
border_variant: neutral().light().step_5(),
|
||||
border_variant: neutral().light().step_4(),
|
||||
border_focused: brand().light().step_7(),
|
||||
border_selected: brand().light().step_7(),
|
||||
border_transparent: gpui::transparent_black(),
|
||||
@@ -203,7 +203,7 @@ impl ThemeColors {
|
||||
window_border: hsl(240.0, 3.7, 28.0),
|
||||
|
||||
border: neutral().dark().step_6(),
|
||||
border_variant: neutral().dark().step_5(),
|
||||
border_variant: neutral().dark().step_4(),
|
||||
border_focused: brand().dark().step_7(),
|
||||
border_selected: brand().dark().step_7(),
|
||||
border_transparent: gpui::transparent_black(),
|
||||
|
||||
@@ -48,7 +48,7 @@ pub trait Panel: EventEmitter<PanelEvent> + Render + Focusable {
|
||||
/// This method will be called when the panel is active or inactive.
|
||||
///
|
||||
/// The last_active_panel and current_active_panel will be touched when the panel is active.
|
||||
fn set_active(&self, _active: bool, _cx: &mut App) {}
|
||||
fn set_active(&self, _active: bool, _window: &mut Window, _cx: &mut App) {}
|
||||
|
||||
/// Set zoomed state of the panel.
|
||||
///
|
||||
@@ -74,7 +74,7 @@ pub trait PanelView: 'static + Send + Sync {
|
||||
fn closable(&self, cx: &App) -> bool;
|
||||
fn zoomable(&self, cx: &App) -> bool;
|
||||
fn visible(&self, cx: &App) -> bool;
|
||||
fn set_active(&self, active: bool, cx: &mut App);
|
||||
fn set_active(&self, active: bool, window: &mut Window, cx: &mut App);
|
||||
fn set_zoomed(&self, zoomed: bool, cx: &mut App);
|
||||
fn popup_menu(&self, menu: PopupMenu, cx: &App) -> PopupMenu;
|
||||
fn toolbar_buttons(&self, window: &Window, cx: &App) -> Vec<Button>;
|
||||
@@ -103,9 +103,9 @@ impl<T: Panel> PanelView for Entity<T> {
|
||||
self.read(cx).visible(cx)
|
||||
}
|
||||
|
||||
fn set_active(&self, active: bool, cx: &mut App) {
|
||||
fn set_active(&self, active: bool, window: &mut Window, cx: &mut App) {
|
||||
self.update(cx, |this, cx| {
|
||||
this.set_active(active, cx);
|
||||
this.set_active(active, window, cx);
|
||||
})
|
||||
}
|
||||
|
||||
@@ -196,8 +196,8 @@ impl gpui_base::dock::PanelView for PanelHandle {
|
||||
self.panel.visible(cx)
|
||||
}
|
||||
|
||||
fn set_active(&self, active: bool, _: &mut Window, cx: &mut App) {
|
||||
self.panel.set_active(active, cx);
|
||||
fn set_active(&self, active: bool, window: &mut Window, cx: &mut App) {
|
||||
self.panel.set_active(active, window, cx);
|
||||
}
|
||||
|
||||
fn set_zoomed(&self, zoomed: bool, _: &mut Window, cx: &mut App) {
|
||||
|
||||
@@ -25,6 +25,7 @@ pub enum IconName {
|
||||
ArrowDownCircle,
|
||||
Boom,
|
||||
Book,
|
||||
Ban,
|
||||
ChevronDown,
|
||||
CaretDown,
|
||||
CaretRight,
|
||||
@@ -50,6 +51,8 @@ pub enum IconName {
|
||||
Link,
|
||||
Loader,
|
||||
Lock,
|
||||
Login,
|
||||
Logout,
|
||||
Message,
|
||||
Moon,
|
||||
Plus,
|
||||
@@ -72,6 +75,7 @@ pub enum IconName {
|
||||
UserKey,
|
||||
Upload,
|
||||
Usb,
|
||||
Edit,
|
||||
PanelLeft,
|
||||
PanelLeftOpen,
|
||||
PanelRight,
|
||||
@@ -104,6 +108,7 @@ impl IconNamed for IconName {
|
||||
Self::ArrowDownCircle => "icons/arrow-down-circle.svg",
|
||||
Self::Boom => "icons/boom.svg",
|
||||
Self::Book => "icons/book.svg",
|
||||
Self::Ban => "icons/ban.svg",
|
||||
Self::ChevronDown => "icons/chevron-down.svg",
|
||||
Self::CaretDown => "icons/caret-down.svg",
|
||||
Self::CaretRight => "icons/caret-right.svg",
|
||||
@@ -129,6 +134,8 @@ impl IconNamed for IconName {
|
||||
Self::Link => "icons/link.svg",
|
||||
Self::Loader => "icons/loader.svg",
|
||||
Self::Lock => "icons/lock.svg",
|
||||
Self::Login => "icons/login.svg",
|
||||
Self::Logout => "icons/logout.svg",
|
||||
Self::Message => "icons/message.svg",
|
||||
Self::Moon => "icons/moon.svg",
|
||||
Self::Plus => "icons/plus.svg",
|
||||
@@ -158,6 +165,7 @@ impl IconNamed for IconName {
|
||||
Self::PanelBottom => "icons/panel-bottom.svg",
|
||||
Self::PanelBottomOpen => "icons/panel-bottom-open.svg",
|
||||
Self::PaperPlaneFill => "icons/paper-plane-fill.svg",
|
||||
Self::Edit => "icons/edit.svg",
|
||||
Self::Warning => "icons/warning.svg",
|
||||
Self::WindowClose => "icons/window-close.svg",
|
||||
Self::WindowMaximize => "icons/window-maximize.svg",
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::Selectable;
|
||||
use crate::avatar::Avatar;
|
||||
use crate::button::Button;
|
||||
use crate::menu::PopupMenu;
|
||||
use crate::nav::Nav;
|
||||
use crate::popover::{Popover, PopoverState};
|
||||
|
||||
/// Builds the items of a popup menu on each render.
|
||||
@@ -41,6 +42,8 @@ impl DropdownMenu for Button {}
|
||||
|
||||
impl DropdownMenu for Avatar {}
|
||||
|
||||
impl DropdownMenu for Nav {}
|
||||
|
||||
#[derive(IntoElement)]
|
||||
pub struct DropdownMenuPopover<T: Selectable + IntoElement + 'static> {
|
||||
id: ElementId,
|
||||
|
||||
+26
-5
@@ -2,9 +2,9 @@ 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,
|
||||
AnyElement, App, ClickEvent, ElementId, InteractiveElement, Interactivity, IntoElement,
|
||||
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
|
||||
Styled, Window, div,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
|
||||
@@ -14,28 +14,43 @@ use crate::{Selectable, StyledExt, h_flex, v_flex};
|
||||
#[derive(IntoElement)]
|
||||
pub struct Nav {
|
||||
id: ElementId,
|
||||
interactivity: Interactivity,
|
||||
style: StyleRefinement,
|
||||
prefix: Option<AnyElement>,
|
||||
label: SharedString,
|
||||
suffix: Option<AnyElement>,
|
||||
selected: bool,
|
||||
clickable: bool,
|
||||
#[allow(clippy::type_complexity)]
|
||||
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||
}
|
||||
|
||||
impl Nav {
|
||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||
let id = id.into();
|
||||
let mut interactivity = Interactivity::default();
|
||||
interactivity.element_id = Some(id.clone());
|
||||
|
||||
Self {
|
||||
id: id.into(),
|
||||
id,
|
||||
interactivity,
|
||||
style: StyleRefinement::default(),
|
||||
prefix: None,
|
||||
label: SharedString::default(),
|
||||
suffix: None,
|
||||
selected: false,
|
||||
clickable: false,
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks the row as clickable even when it has no `on_click` handler, so it
|
||||
/// still shows the hover affordance. Used by rows whose click opens a menu.
|
||||
pub fn clickable(mut self, clickable: bool) -> Self {
|
||||
self.clickable = clickable;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the element shown before the label, such as an avatar or icon.
|
||||
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
|
||||
self.prefix = Some(prefix.into_any_element());
|
||||
@@ -74,6 +89,12 @@ impl Selectable for Nav {
|
||||
}
|
||||
}
|
||||
|
||||
impl InteractiveElement for Nav {
|
||||
fn interactivity(&mut self) -> &mut Interactivity {
|
||||
&mut self.interactivity
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for Nav {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
@@ -82,7 +103,7 @@ impl Styled for Nav {
|
||||
|
||||
impl RenderOnce for Nav {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let clickable = self.on_click.is_some();
|
||||
let clickable = self.on_click.is_some() || self.clickable;
|
||||
|
||||
v_flex().w_full().h_10().child(
|
||||
h_flex()
|
||||
|
||||
@@ -12,8 +12,8 @@ state = { path = "../state" }
|
||||
device = { path = "../device" }
|
||||
chat = { path = "../chat" }
|
||||
chat_ui = { path = "../chat_ui" }
|
||||
community = { path = "../community" }
|
||||
community_ui = { path = "../community_ui" }
|
||||
nip29 = { path = "../nip29" }
|
||||
nip29_ui = { path = "../nip29_ui" }
|
||||
settings = { path = "../settings" }
|
||||
person = { path = "../person" }
|
||||
auto_update = { path = "../auto_update" }
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
use gpui::{App, AppContext, ParentElement, Styled, Window, px};
|
||||
use nip29::{GroupReference, GroupsRegistry};
|
||||
use ui::WindowExtension;
|
||||
use ui::input::{Input, InputState};
|
||||
use ui::notification::Notification;
|
||||
|
||||
pub fn open(window: &mut Window, cx: &mut App) {
|
||||
let input = cx.new(|cx| InputState::new(window, cx).placeholder("naddr1…"));
|
||||
|
||||
window.open_dialog(cx, move |this, _window, _cx| {
|
||||
let input = input.clone();
|
||||
|
||||
this.width(px(380.))
|
||||
.confirm()
|
||||
.title("Join Group")
|
||||
.child(Input::new(&input).cleanable(true).flex_1().text_sm())
|
||||
.on_ok(move |_event, window, cx| {
|
||||
let groups = GroupsRegistry::global(cx);
|
||||
|
||||
let reference = match GroupReference::parse(input.read(cx).value().trim()) {
|
||||
Ok(reference) => reference,
|
||||
Err(error) => {
|
||||
window.push_notification(Notification::error(error.to_string()), cx);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
groups.update(cx, |registry, cx| {
|
||||
registry.join(reference, cx);
|
||||
});
|
||||
|
||||
true
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
pub mod import;
|
||||
pub mod join_group;
|
||||
pub mod new_chat;
|
||||
pub mod new_community;
|
||||
pub mod new_group;
|
||||
pub mod profile;
|
||||
pub mod restore;
|
||||
pub mod screening;
|
||||
pub mod settings;
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
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_dialog(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 community = CommunityRegistry::global(cx);
|
||||
let name = name_input.read(cx).value().trim().to_owned();
|
||||
|
||||
if name.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let metadata = CommunityMetadata {
|
||||
name,
|
||||
..CommunityMetadata::default()
|
||||
};
|
||||
|
||||
community.update(cx, |registry, cx| {
|
||||
registry.create(metadata, cx);
|
||||
});
|
||||
|
||||
true
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use gpui::{App, AppContext, Div, Entity, ParentElement, Styled, Window, px};
|
||||
use nip29::{GroupId, GroupKey, GroupMetadata, GroupsRegistry};
|
||||
use nostr_sdk::prelude::*;
|
||||
use theme::ActiveTheme;
|
||||
use ui::input::{Input, InputState};
|
||||
use ui::notification::Notification;
|
||||
use ui::{WindowExtension, v_flex};
|
||||
|
||||
pub fn open(window: &mut Window, cx: &mut App) {
|
||||
let name = cx.new(|cx| InputState::new(window, cx).placeholder("Group name"));
|
||||
let id = cx.new(|cx| InputState::new(window, cx).placeholder("Group id"));
|
||||
let relay = cx.new(|cx| InputState::new(window, cx).placeholder("wss://relay.example.com"));
|
||||
|
||||
window.open_dialog(cx, move |this, _window, cx| {
|
||||
let name = name.clone();
|
||||
let id = id.clone();
|
||||
let relay = relay.clone();
|
||||
|
||||
this.width(px(380.))
|
||||
.confirm()
|
||||
.title("New group")
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(field("Name", &name, cx))
|
||||
.child(field("Group id", &id, cx))
|
||||
.child(field("Relay", &relay, cx)),
|
||||
)
|
||||
.on_ok(move |_event, window, cx| create(&name, &id, &relay, window, cx))
|
||||
});
|
||||
}
|
||||
|
||||
fn field(label: &'static str, input: &Entity<InputState>, cx: &App) -> Div {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(label)
|
||||
.child(Input::new(input))
|
||||
}
|
||||
|
||||
fn create(
|
||||
name: &Entity<InputState>,
|
||||
id: &Entity<InputState>,
|
||||
relay: &Entity<InputState>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> bool {
|
||||
let name_value = name.read(cx).value().trim().to_owned();
|
||||
let raw_id = id.read(cx).value().trim().to_owned();
|
||||
let raw_relay = relay.read(cx).value().trim().to_owned();
|
||||
|
||||
if name_value.is_empty() {
|
||||
window.push_notification(Notification::error("Enter a group name"), cx);
|
||||
return false;
|
||||
}
|
||||
|
||||
let id = if raw_id.is_empty() {
|
||||
slug(&name_value)
|
||||
} else {
|
||||
raw_id
|
||||
};
|
||||
|
||||
let id = match GroupId::new(id) {
|
||||
Ok(id) => id,
|
||||
Err(error) => {
|
||||
window.push_notification(Notification::error(error.to_string()), cx);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let relay = match RelayUrl::parse(&raw_relay) {
|
||||
Ok(relay) => relay,
|
||||
Err(error) => {
|
||||
window.push_notification(Notification::error(error.to_string()), cx);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let mut metadata = GroupMetadata::default();
|
||||
metadata.name = Some(name_value);
|
||||
|
||||
let registry = GroupsRegistry::global(cx);
|
||||
registry.update(cx, |registry, cx| {
|
||||
registry.create(GroupKey::new(relay, id), metadata, cx);
|
||||
});
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn slug(name: &str) -> String {
|
||||
let mut slug = String::with_capacity(name.len());
|
||||
let mut separator = false;
|
||||
|
||||
for character in name.chars() {
|
||||
if character.is_ascii_alphanumeric() {
|
||||
slug.push(character.to_ascii_lowercase());
|
||||
separator = false;
|
||||
} else if !separator && !slug.is_empty() {
|
||||
slug.push('-');
|
||||
separator = true;
|
||||
}
|
||||
}
|
||||
|
||||
slug.trim_end_matches('-').to_owned()
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
use anyhow::Error;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
App, AppContext, ClipboardItem, Context, Div, IntoElement, ParentElement, Render, SharedString,
|
||||
Styled, Task, Window, div, px,
|
||||
};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{Person, PersonRegistry, shorten_pubkey};
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::{IconName, Sizable, WindowExtension, h_flex, v_flex};
|
||||
|
||||
/// Open a read-only profile for `public_key`.
|
||||
pub fn open(public_key: PublicKey, window: &mut Window, cx: &mut App) {
|
||||
let profile = cx.new(|_| ProfileDialog::new(public_key));
|
||||
|
||||
window.open_dialog(cx, move |this, _window, _cx| {
|
||||
this.width(px(360.)).title("Profile").child(profile.clone())
|
||||
});
|
||||
}
|
||||
|
||||
struct ProfileDialog {
|
||||
public_key: PublicKey,
|
||||
copied: bool,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
}
|
||||
|
||||
impl ProfileDialog {
|
||||
fn new(public_key: PublicKey) -> Self {
|
||||
Self {
|
||||
public_key,
|
||||
copied: false,
|
||||
tasks: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn profile(&self, cx: &App) -> Person {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
persons.read(cx).get(&self.public_key, cx)
|
||||
}
|
||||
|
||||
fn copy(&mut self, value: String, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let item = ClipboardItem::new_string(value);
|
||||
cx.write_to_clipboard(item);
|
||||
|
||||
self.set_copied(true, window, cx);
|
||||
}
|
||||
|
||||
fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.copied = status;
|
||||
cx.notify();
|
||||
|
||||
if status {
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
|
||||
// Reset the copied state after a delay
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_copied(false, window, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ProfileDialog {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let profile = self.profile(cx);
|
||||
let metadata = profile.metadata();
|
||||
|
||||
// Get the public key as short string
|
||||
let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
|
||||
|
||||
let about = metadata
|
||||
.about
|
||||
.filter(|about| !about.trim().is_empty())
|
||||
.map(SharedString::from);
|
||||
|
||||
let website = metadata
|
||||
.website
|
||||
.filter(|website| !website.trim().is_empty())
|
||||
.map(SharedString::from);
|
||||
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.w_full()
|
||||
.child(
|
||||
v_flex()
|
||||
.h_40()
|
||||
.w_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.child(
|
||||
Avatar::new(profile.avatar())
|
||||
.seed(profile.avatar_seed())
|
||||
.large(),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1p5()
|
||||
.child(label("Name", cx))
|
||||
.child(div().text_sm().child(profile.name())),
|
||||
)
|
||||
.when_some(about, |this, about| {
|
||||
this.child(
|
||||
v_flex()
|
||||
.gap_1p5()
|
||||
.child(label("About", cx))
|
||||
.child(div().text_sm().child(about)),
|
||||
)
|
||||
})
|
||||
.when_some(website, |this, website| {
|
||||
this.child(
|
||||
v_flex()
|
||||
.gap_1p5()
|
||||
.child(label("Website", cx))
|
||||
.child(div().text_sm().child(website)),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
v_flex().gap_1p5().child(label("Public Key", cx)).child(
|
||||
h_flex()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.justify_center()
|
||||
.gap_3()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(cx.theme().secondary_background)
|
||||
.text_sm()
|
||||
.text_color(cx.theme().secondary_foreground)
|
||||
.child(shorten_pkey)
|
||||
.child(
|
||||
Button::new("copy")
|
||||
.icon({
|
||||
if self.copied {
|
||||
IconName::CheckCircle
|
||||
} else {
|
||||
IconName::Copy
|
||||
}
|
||||
})
|
||||
.xsmall()
|
||||
.secondary()
|
||||
.on_click(cx.listener(move |this, _ev, window, cx| {
|
||||
this.copy(this.public_key.to_bech32().unwrap(), window, cx);
|
||||
})),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn label(label: &'static str, cx: &App) -> Div {
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(label)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use gpui::{
|
||||
Action, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
||||
Render, SharedString, Styled, Subscription, Task, Window, div, px,
|
||||
};
|
||||
use nip29::{GroupId, GroupKey};
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
@@ -21,7 +22,7 @@ use ui::notification::{Notification, NotificationKind};
|
||||
use ui::{IconName, Root, Sizable, WindowExtension, h_flex, v_flex};
|
||||
|
||||
use crate::dialogs::restore::RestoreEncryption;
|
||||
use crate::dialogs::{new_chat, new_community, settings};
|
||||
use crate::dialogs::{join_group, new_chat, new_group, settings};
|
||||
use crate::panels::{
|
||||
backup, browse, contact_list, greeter, inbox, messaging_relays, profile, relay_list, requests,
|
||||
search,
|
||||
@@ -60,11 +61,13 @@ enum Command {
|
||||
ShowBrowse,
|
||||
ShowSearch,
|
||||
NewChat,
|
||||
NewCommunity,
|
||||
NewGroup,
|
||||
JoinGroup,
|
||||
}
|
||||
|
||||
pub struct Workspace {
|
||||
dock: Entity<DockArea>,
|
||||
sidebar: Entity<Sidebar>,
|
||||
/// Async tasks
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
/// Event subscriptions
|
||||
@@ -79,6 +82,7 @@ impl Workspace {
|
||||
|
||||
let (dock, _) = dock::dock_area("coop", window, cx);
|
||||
let sidebar = cx.new(|cx| Sidebar::new(window, dock.downgrade(), cx));
|
||||
let left_sidebar = sidebar.clone();
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
@@ -186,7 +190,7 @@ impl Workspace {
|
||||
);
|
||||
|
||||
cx.defer_in(window, move |this, window, cx| {
|
||||
let sidebar = PanelHandle::new(sidebar);
|
||||
let sidebar = PanelHandle::new(left_sidebar);
|
||||
let greeter = PanelHandle::new(greeter::init(window, cx));
|
||||
|
||||
this.dock.update(cx, |this, cx| {
|
||||
@@ -200,8 +204,32 @@ impl Workspace {
|
||||
});
|
||||
});
|
||||
|
||||
// Listen app-wide, so a group panel can show or hide the sidebar without holding it.
|
||||
let workspace = cx.entity().downgrade();
|
||||
|
||||
App::on_action(cx, {
|
||||
let workspace = workspace.clone();
|
||||
|
||||
move |action: &nip29_ui::ShowGroupMeta, cx| {
|
||||
let Some(workspace) = workspace.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
workspace.update(cx, |this, cx| this.show_group_meta(action, cx));
|
||||
}
|
||||
});
|
||||
|
||||
App::on_action(cx, move |action: &nip29_ui::HideGroupMeta, cx| {
|
||||
let Some(workspace) = workspace.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
workspace.update(cx, |this, cx| this.hide_group_meta(action, cx));
|
||||
});
|
||||
|
||||
Self {
|
||||
dock,
|
||||
sidebar,
|
||||
tasks: vec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
@@ -282,7 +310,8 @@ impl Workspace {
|
||||
);
|
||||
}
|
||||
Command::ShowBrowse => {
|
||||
self.add_panel_to_dock(browse::init(window, cx), DockPlacement::Center, window, cx);
|
||||
let panel = browse::init(self.dock.downgrade(), window, cx);
|
||||
self.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
|
||||
}
|
||||
Command::ShowSearch => {
|
||||
let panel = search::init(self.dock.downgrade(), window, cx);
|
||||
@@ -291,8 +320,11 @@ impl Workspace {
|
||||
Command::NewChat => {
|
||||
new_chat::open(self.dock.downgrade(), window, cx);
|
||||
}
|
||||
Command::NewCommunity => {
|
||||
new_community::open(window, cx);
|
||||
Command::NewGroup => {
|
||||
new_group::open(window, cx);
|
||||
}
|
||||
Command::JoinGroup => {
|
||||
join_group::open(window, cx);
|
||||
}
|
||||
Command::ShowBackup => {
|
||||
self.add_panel_to_dock(backup::init(window, cx), DockPlacement::Left, window, cx);
|
||||
@@ -377,6 +409,32 @@ impl Workspace {
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the sidebar's information for the group a panel belongs to.
|
||||
fn show_group_meta(&mut self, action: &nip29_ui::ShowGroupMeta, cx: &mut Context<Self>) {
|
||||
let Ok(id) = GroupId::new(action.group.clone()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let key = GroupKey::new(action.relay.clone(), id);
|
||||
|
||||
self.sidebar.update(cx, |sidebar, cx| {
|
||||
sidebar.show_group_meta(&key, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Hide the sidebar's information for the group a panel belongs to.
|
||||
fn hide_group_meta(&mut self, action: &nip29_ui::HideGroupMeta, cx: &mut Context<Self>) {
|
||||
let Ok(id) = GroupId::new(action.group.clone()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let key = GroupKey::new(action.relay.clone(), id);
|
||||
|
||||
self.sidebar.update(cx, |sidebar, cx| {
|
||||
sidebar.hide_group_meta(&key, cx);
|
||||
});
|
||||
}
|
||||
|
||||
fn confirm_reset_encryption(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
const ENC_MSG: &str = "Encryption Key is a special key that used to encrypt and decrypt your messages. \
|
||||
Your identity is completely decoupled from all encryption processes to protect your privacy.";
|
||||
|
||||
@@ -1,32 +1,249 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
IntoElement, ParentElement, Render, SharedString, Styled, Window,
|
||||
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, WeakEntity, Window,
|
||||
div, uniform_list,
|
||||
};
|
||||
use nip29::{GroupCandidate, GroupKey, GroupsEvent, GroupsRegistry};
|
||||
use nostr_sdk::prelude::RelayUrl;
|
||||
use settings::AppSettings;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use theme::ActiveTheme;
|
||||
use ui::dock::{Panel, PanelEvent};
|
||||
use ui::{Icon, IconName, Sizable, h_flex};
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
|
||||
use ui::indicator::Indicator;
|
||||
use ui::input::{Input, InputEvent, InputState};
|
||||
use ui::notification::Notification;
|
||||
use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<BrowsePanel> {
|
||||
cx.new(|cx| BrowsePanel::new(window, cx))
|
||||
const TITLE: &str = "Browse";
|
||||
const INPUT_PLACEHOLDER: &str = "wss://relay.example.com";
|
||||
|
||||
pub fn init(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) -> Entity<BrowsePanel> {
|
||||
cx.new(|cx| BrowsePanel::new(dock, window, cx))
|
||||
}
|
||||
|
||||
pub struct BrowsePanel {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
dock: WeakEntity<DockArea>,
|
||||
/// The relay input
|
||||
input: Entity<InputState>,
|
||||
/// The relay currently being browsed.
|
||||
relay: Option<RelayUrl>,
|
||||
/// The groups the relay has announced so far
|
||||
candidates: Vec<GroupCandidate>,
|
||||
/// Whether the relay is still answering
|
||||
loading: bool,
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
|
||||
impl BrowsePanel {
|
||||
fn new(_window: &mut Window, cx: &mut App) -> Self {
|
||||
fn new(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let input = cx.new(|cx| InputState::new(window, cx).placeholder(INPUT_PLACEHOLDER));
|
||||
let registry = GroupsRegistry::global(cx);
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.browse(window, cx);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
cx.subscribe(®istry, |this, _registry, event, cx| match event {
|
||||
GroupsEvent::Browsed(candidate) => {
|
||||
this.push_candidate(candidate.as_ref().clone(), cx)
|
||||
}
|
||||
GroupsEvent::BrowseSettled(relay) => {
|
||||
this.settle(relay.clone(), cx);
|
||||
}
|
||||
_ => {}
|
||||
}),
|
||||
);
|
||||
|
||||
Self {
|
||||
name: "Browse".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock,
|
||||
input,
|
||||
relay: None,
|
||||
candidates: Vec::new(),
|
||||
loading: false,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Browse the relay in the input.
|
||||
fn browse(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let registry = GroupsRegistry::global(cx);
|
||||
let value = self.input.read(cx).value().trim().to_owned();
|
||||
|
||||
if value.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let relay = match RelayUrl::parse(&value) {
|
||||
Ok(relay) => relay,
|
||||
Err(error) => {
|
||||
window.push_notification(Notification::error(error.to_string()), cx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
self.relay = Some(relay.clone());
|
||||
self.candidates.clear();
|
||||
self.loading = true;
|
||||
|
||||
registry.update(cx, |registry, cx| {
|
||||
registry.browse(relay, cx);
|
||||
});
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn push_candidate(&mut self, candidate: GroupCandidate, cx: &mut Context<Self>) {
|
||||
if self.relay.as_ref() != Some(candidate.key.relay()) {
|
||||
return;
|
||||
}
|
||||
|
||||
match self.candidates.iter_mut().find(|e| e.key == candidate.key) {
|
||||
Some(existing) => *existing = candidate,
|
||||
None => self.candidates.push(candidate),
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// The relay stopped answering: EOSE, a refusal, or the auto-close timeout.
|
||||
fn settle(&mut self, relay: RelayUrl, cx: &mut Context<Self>) {
|
||||
if self.relay.as_ref() != Some(&relay) {
|
||||
return;
|
||||
}
|
||||
self.loading = false;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Join a browsed group and open it.
|
||||
fn join(&self, key: GroupKey, window: &mut Window, cx: &mut App) {
|
||||
let dock = self.dock.clone();
|
||||
let registry = GroupsRegistry::global(cx);
|
||||
|
||||
registry.update(cx, |registry, cx| {
|
||||
registry.join_key(key.clone(), None, cx);
|
||||
});
|
||||
|
||||
let Some(group) = registry.read(cx).group(&key, cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
ui::dock::add_panel_to(
|
||||
&dock,
|
||||
PanelHandle::new(nip29_ui::init(group, window, cx)),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_rows(&self, range: Range<usize>, cx: &mut Context<Self>) -> Vec<AnyElement> {
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
let panel = cx.entity().downgrade();
|
||||
|
||||
let registry = GroupsRegistry::global(cx);
|
||||
let joined: Vec<GroupKey> = registry
|
||||
.read(cx)
|
||||
.groups()
|
||||
.iter()
|
||||
.map(|group| group.read(cx).key().clone())
|
||||
.collect();
|
||||
|
||||
self.candidates
|
||||
.get(range)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|candidate| {
|
||||
let key = candidate.key.clone();
|
||||
let name = SharedString::from(candidate.display_name().to_owned());
|
||||
let picture = candidate
|
||||
.display_image()
|
||||
.map(|picture| SharedString::from(picture.to_owned()));
|
||||
let seed = key.cache_tag();
|
||||
let is_joined = joined.contains(&key);
|
||||
|
||||
let action = if is_joined {
|
||||
Button::new(format!("browse-joined-{seed}"))
|
||||
.label("Joined")
|
||||
.small()
|
||||
.disabled(true)
|
||||
.into_any_element()
|
||||
} else {
|
||||
let panel = panel.clone();
|
||||
let clicked = key.clone();
|
||||
|
||||
Button::new(format!("browse-join-{seed}"))
|
||||
.label("Join")
|
||||
.small()
|
||||
.on_click(move |_event, window, cx| {
|
||||
if let Err(error) = panel.update(cx, |panel, cx| {
|
||||
panel.join(clicked.clone(), window, cx);
|
||||
}) {
|
||||
log::error!("Failed to join a browsed group: {error}");
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.px_1p5()
|
||||
.py_1()
|
||||
.when(!hide_avatar, |this| {
|
||||
this.child(
|
||||
Avatar::from_source(picture)
|
||||
.seed(seed)
|
||||
.small()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(div().text_sm().font_medium().truncate().child(name))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.truncate()
|
||||
.child(SharedString::from(key.id().as_str().to_owned())),
|
||||
),
|
||||
)
|
||||
.child(action)
|
||||
.into_any_element()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn notice(text: &'static str, cx: &App) -> AnyElement {
|
||||
h_flex()
|
||||
.size_full()
|
||||
.justify_center()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(text)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
impl Panel for BrowsePanel {
|
||||
fn panel_id(&self) -> SharedString {
|
||||
self.name.clone()
|
||||
TITLE.into()
|
||||
}
|
||||
|
||||
fn title(&self, cx: &App) -> AnyElement {
|
||||
@@ -37,7 +254,7 @@ impl Panel for BrowsePanel {
|
||||
.small()
|
||||
.text_color(cx.theme().icon_muted),
|
||||
)
|
||||
.child(self.name.clone())
|
||||
.child(TITLE)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -52,11 +269,44 @@ impl Focusable for BrowsePanel {
|
||||
|
||||
impl Render for BrowsePanel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
let count = self.candidates.len();
|
||||
|
||||
let body = if self.relay.is_none() {
|
||||
notice("Enter a relay to browse its groups.", cx)
|
||||
} else if count > 0 {
|
||||
uniform_list(
|
||||
"browse-groups",
|
||||
count,
|
||||
cx.processor(|this, range, _window, cx| this.render_rows(range, cx)),
|
||||
)
|
||||
.h_full()
|
||||
.into_any_element()
|
||||
} else if self.loading {
|
||||
notice("Loading groups…", cx)
|
||||
} else {
|
||||
notice("No groups found.", cx)
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.justify_center()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(self.name.clone())
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.p_3()
|
||||
.flex_shrink_0()
|
||||
.child(Input::new(&self.input).cleanable(true).flex_1())
|
||||
.child(
|
||||
Button::new("browse-go")
|
||||
.label("Go")
|
||||
.primary()
|
||||
.on_click(cx.listener(|this, _event, window, cx| {
|
||||
this.browse(window, cx);
|
||||
})),
|
||||
)
|
||||
.when(self.loading, |this| {
|
||||
this.child(Indicator::new().small().color(cx.theme().icon_accent))
|
||||
}),
|
||||
)
|
||||
.child(div().flex_1().min_h_0().child(body))
|
||||
}
|
||||
}
|
||||
|
||||
+365
-263
@@ -6,14 +6,14 @@ use anyhow::Error;
|
||||
use auto_update::AutoUpdater;
|
||||
use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
|
||||
use common::TimestampExt;
|
||||
use community::{ChannelId, Community, CommunityEvent, CommunityRegistry};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, Context, Div, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString, Stateful,
|
||||
Styled, StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, div, img,
|
||||
px, retain_all, uniform_list,
|
||||
AnyElement, App, AppContext, ClipboardItem, Context, Div, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString,
|
||||
Stateful, Styled, StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window,
|
||||
div, img, px, relative, retain_all, uniform_list,
|
||||
};
|
||||
use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry, Membership};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use settings::AppSettings;
|
||||
@@ -38,70 +38,79 @@ use ui::{
|
||||
};
|
||||
|
||||
use crate::Command;
|
||||
use crate::dialogs::{import, screening};
|
||||
use crate::dialogs::{import, profile, screening};
|
||||
|
||||
mod tab;
|
||||
mod utils;
|
||||
|
||||
use tab::SidebarTab;
|
||||
pub(crate) use utils::{nav_icon, pick_banner};
|
||||
pub(crate) use utils::pick_banner;
|
||||
|
||||
pub enum SidebarRow {
|
||||
Room { room: Entity<Room> },
|
||||
Community { community: Entity<Community> },
|
||||
Group { group: Entity<Group> },
|
||||
Contact { public_key: PublicKey },
|
||||
}
|
||||
|
||||
/// A collapsible group of rows in the sidebar's community view.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CommunitySection {
|
||||
Channels,
|
||||
/// A collapsible section of the group view that replaces the tab list.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum GroupSection {
|
||||
Admins,
|
||||
Members,
|
||||
}
|
||||
|
||||
/// A row in the sidebar's community view.
|
||||
pub enum CommunityRow {
|
||||
Section(CommunitySection),
|
||||
Channel {
|
||||
id: ChannelId,
|
||||
name: SharedString,
|
||||
private: bool,
|
||||
selected: bool,
|
||||
},
|
||||
impl GroupSection {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Admins => "Admins",
|
||||
Self::Members => "Members",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A row in the group view.
|
||||
enum GroupRow {
|
||||
Section(GroupSection),
|
||||
Member {
|
||||
public_key: PublicKey,
|
||||
roles: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct Sidebar {
|
||||
focus_handle: FocusHandle,
|
||||
scroll_handles: [UniformListScrollHandle; 2],
|
||||
/// Scroll state of the community's channel and member lists
|
||||
community_scroll: UniformListScrollHandle,
|
||||
/// The dock the sidebar opens its panels in
|
||||
dock: WeakEntity<DockArea>,
|
||||
/// Background shown behind the signed-out screen, picked at random
|
||||
/// Random background shown behind the signed-out screen.
|
||||
banner: SharedString,
|
||||
active_tab: SidebarTab,
|
||||
/// The community the sidebar is browsing, if any
|
||||
community: Option<WeakEntity<Community>>,
|
||||
/// Whether the inbox list is showing contacts to start a chat with
|
||||
contacts_open: bool,
|
||||
/// The signed-in user's contacts, loaded when the contact picker opens
|
||||
contacts: Option<Vec<PublicKey>>,
|
||||
/// Contacts selected in the contact picker
|
||||
selected_contacts: HashSet<PublicKey>,
|
||||
/// Scroll state of the contact picker's list
|
||||
contacts_scroll: UniformListScrollHandle,
|
||||
/// Whether the inbox list is showing chat requests instead of rooms
|
||||
requests_open: bool,
|
||||
/// Scroll state of the requests list
|
||||
requests_scroll: UniformListScrollHandle,
|
||||
channels_open: bool,
|
||||
|
||||
/// The group whose information the sidebar is browsing, if any.
|
||||
group: Option<WeakEntity<Group>>,
|
||||
/// Whether the sidebar is showing the group's information instead of its tabs.
|
||||
group_open: bool,
|
||||
/// Scroll state of the group's member list.
|
||||
group_scroll: UniformListScrollHandle,
|
||||
/// Whether the group view's admins section is expanded.
|
||||
admins_open: bool,
|
||||
/// Whether the group view's members section is expanded.
|
||||
members_open: bool,
|
||||
|
||||
/// Whether the inbox list is showing contacts to start a chat with.
|
||||
contacts_open: bool,
|
||||
/// The signed-in user's contacts, loaded when the contact picker opens.
|
||||
contacts: Option<Vec<PublicKey>>,
|
||||
/// Contacts selected in the contact picker.
|
||||
selected_contacts: HashSet<PublicKey>,
|
||||
/// Scroll state of the contact picker's list.
|
||||
contacts_scroll: UniformListScrollHandle,
|
||||
|
||||
/// Whether the inbox list is showing chat requests instead of rooms.
|
||||
requests_open: bool,
|
||||
/// Scroll state of the requests list.
|
||||
requests_scroll: UniformListScrollHandle,
|
||||
new_requests: bool,
|
||||
|
||||
tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
|
||||
_subscriptions: SmallVec<[Subscription; 4]>,
|
||||
}
|
||||
@@ -109,7 +118,7 @@ pub struct Sidebar {
|
||||
impl Sidebar {
|
||||
pub fn new(window: &mut Window, dock: WeakEntity<DockArea>, cx: &mut Context<Self>) -> Self {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let communities = CommunityRegistry::global(cx);
|
||||
let groups = GroupsRegistry::global(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
@@ -123,23 +132,23 @@ impl Sidebar {
|
||||
}),
|
||||
);
|
||||
|
||||
// Keep the room and request counts in sync with the chat registry.
|
||||
subscriptions.push(cx.observe(&chat, |_this, _chat, cx| {
|
||||
cx.notify();
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&communities,
|
||||
window,
|
||||
|_this, _communities, event, window, cx| {
|
||||
if let CommunityEvent::Error(error) = event {
|
||||
window.push_notification(Notification::error(error.clone()), cx);
|
||||
}
|
||||
},
|
||||
));
|
||||
subscriptions.push(
|
||||
cx.subscribe_in(&groups, window, |_this, _, event, window, cx| {
|
||||
match event {
|
||||
GroupsEvent::Updated => {
|
||||
// TODO: find a better way?
|
||||
cx.notify();
|
||||
}
|
||||
GroupsEvent::Error(error) => {
|
||||
window.push_notification(Notification::error(error.clone()), cx);
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
subscriptions.push(
|
||||
cx.subscribe_in(&nostr, window, |this, _nostr, event, _window, cx| {
|
||||
cx.subscribe_in(&nostr, window, |this, _, event, _window, cx| {
|
||||
// Re-pick the background each time the signed-out screen is shown.
|
||||
if let StateEvent::NoSigner = event {
|
||||
this.banner = pick_banner();
|
||||
@@ -154,26 +163,67 @@ impl Sidebar {
|
||||
UniformListScrollHandle::new(),
|
||||
UniformListScrollHandle::new(),
|
||||
],
|
||||
community_scroll: UniformListScrollHandle::new(),
|
||||
dock,
|
||||
banner: pick_banner(),
|
||||
active_tab: SidebarTab::Inbox,
|
||||
community: None,
|
||||
group: None,
|
||||
group_open: false,
|
||||
group_scroll: UniformListScrollHandle::new(),
|
||||
admins_open: true,
|
||||
members_open: true,
|
||||
contacts_open: false,
|
||||
contacts: None,
|
||||
selected_contacts: HashSet::new(),
|
||||
contacts_scroll: UniformListScrollHandle::new(),
|
||||
requests_open: false,
|
||||
requests_scroll: UniformListScrollHandle::new(),
|
||||
channels_open: true,
|
||||
admins_open: true,
|
||||
members_open: true,
|
||||
new_requests: false,
|
||||
tasks: smallvec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the group view, replacing the tab list.
|
||||
fn show_group(&mut self, group: &Entity<Group>, cx: &mut Context<Self>) {
|
||||
self.group = Some(group.downgrade());
|
||||
self.group_open = true;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Show the group view without toggling it closed.
|
||||
pub(crate) fn show_group_meta(&mut self, key: &GroupKey, cx: &mut Context<Self>) {
|
||||
let groups = GroupsRegistry::global(cx);
|
||||
|
||||
let Some(group) = groups.read(cx).group(key, cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.show_group(&group, cx);
|
||||
}
|
||||
|
||||
/// Hide the group view if the sidebar is showing the given group.
|
||||
pub(crate) fn hide_group_meta(&mut self, key: &GroupKey, cx: &mut Context<Self>) {
|
||||
let showing = self
|
||||
.group
|
||||
.as_ref()
|
||||
.and_then(|group| group.upgrade())
|
||||
.is_some_and(|group| group.read(cx).key() == key);
|
||||
|
||||
if showing {
|
||||
self.hide_group(cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Leave the group view, returning the sidebar to its tab list.
|
||||
fn hide_group(&mut self, cx: &mut Context<Self>) {
|
||||
if !self.group_open {
|
||||
return;
|
||||
}
|
||||
|
||||
self.group_open = false;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn select_tab(&mut self, tab: SidebarTab, cx: &mut Context<Self>) {
|
||||
if self.active_tab == tab {
|
||||
return;
|
||||
@@ -328,14 +378,6 @@ impl Sidebar {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Leave the community view, returning the sidebar to its tab list.
|
||||
fn reset_community(&mut self, cx: &mut Context<Self>) {
|
||||
if self.community.take().is_none() {
|
||||
return;
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_user(&self, current_user: &PublicKey, cx: &mut Context<Self>) -> Stateful<Div> {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let profile = persons.read(cx).get(current_user, cx);
|
||||
@@ -418,7 +460,7 @@ impl Sidebar {
|
||||
.when_some(AutoUpdater::try_global(cx), |this, updater| {
|
||||
this.child(self.render_updater(updater, cx))
|
||||
})
|
||||
.when(self.community.is_some(), |this| {
|
||||
.when(self.group_open, |this| {
|
||||
this.child(
|
||||
Button::new("sidebar-back")
|
||||
.icon(IconName::ArrowLeft)
|
||||
@@ -426,7 +468,7 @@ impl Sidebar {
|
||||
.ghost()
|
||||
.small()
|
||||
.on_click(cx.listener(|this, _event, _window, cx| {
|
||||
this.reset_community(cx);
|
||||
this.hide_group(cx);
|
||||
})),
|
||||
)
|
||||
})
|
||||
@@ -504,7 +546,7 @@ impl Sidebar {
|
||||
.segmented(true)
|
||||
.selected_index(active_tab.index())
|
||||
.child(Tab::new().label(SidebarTab::Inbox.label()))
|
||||
.child(Tab::new().label(SidebarTab::Communities.label()))
|
||||
.child(Tab::new().label(SidebarTab::Groups.label()))
|
||||
.on_click({
|
||||
let sidebar = sidebar.clone();
|
||||
move |index, _window, cx| {
|
||||
@@ -580,18 +622,28 @@ impl Sidebar {
|
||||
})),
|
||||
),
|
||||
),
|
||||
SidebarTab::Communities => this.child(
|
||||
SidebarTab::Groups => this.child(
|
||||
v_flex()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.child(
|
||||
NavItem::new(
|
||||
"new-community",
|
||||
"New Community",
|
||||
"new-group",
|
||||
"New Group",
|
||||
Icon::new(IconName::Group).small(),
|
||||
)
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(Box::new(Command::NewCommunity), cx)
|
||||
window.dispatch_action(Box::new(Command::NewGroup), cx)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
NavItem::new(
|
||||
"join-group",
|
||||
"Join Group",
|
||||
Icon::new(IconName::Door).small(),
|
||||
)
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(Box::new(Command::JoinGroup), cx)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
@@ -668,127 +720,6 @@ impl Sidebar {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_community(
|
||||
&mut self,
|
||||
community: Entity<Community>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let (banner, rows) = {
|
||||
let community = community.read(cx);
|
||||
let owner = community.state().owner;
|
||||
|
||||
let mut admins = Vec::new();
|
||||
let mut members = Vec::new();
|
||||
|
||||
for public_key in community.members() {
|
||||
if community.control().roles.is_staff(public_key, &owner) {
|
||||
admins.push(*public_key);
|
||||
} else {
|
||||
members.push(*public_key);
|
||||
}
|
||||
}
|
||||
|
||||
let active = community.active_channel();
|
||||
let banner = community.banner();
|
||||
|
||||
let mut rows = vec![CommunityRow::Section(CommunitySection::Channels)];
|
||||
if self.channels_open {
|
||||
rows.extend(
|
||||
community
|
||||
.channels()
|
||||
.iter()
|
||||
.map(|channel| CommunityRow::Channel {
|
||||
id: channel.id,
|
||||
name: channel.name.clone().into(),
|
||||
private: channel.private,
|
||||
selected: active == Some(channel.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
rows.push(CommunityRow::Section(CommunitySection::Admins));
|
||||
if self.admins_open {
|
||||
rows.extend(
|
||||
admins
|
||||
.into_iter()
|
||||
.map(|public_key| CommunityRow::Member { public_key }),
|
||||
);
|
||||
}
|
||||
|
||||
rows.push(CommunityRow::Section(CommunitySection::Members));
|
||||
if self.members_open {
|
||||
rows.extend(
|
||||
members
|
||||
.into_iter()
|
||||
.map(|public_key| CommunityRow::Member { public_key }),
|
||||
);
|
||||
}
|
||||
|
||||
(banner, rows)
|
||||
};
|
||||
|
||||
let rows = Rc::new(rows);
|
||||
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.when_some(banner, |this, banner| {
|
||||
this.child(
|
||||
div().px_2().flex_shrink_0().child(
|
||||
img(banner)
|
||||
.w_full()
|
||||
.h_20()
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.object_fit(ObjectFit::Cover),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.min_h_0()
|
||||
.flex_1()
|
||||
.child(
|
||||
uniform_list(
|
||||
"community-rows",
|
||||
rows.len(),
|
||||
cx.processor(move |this, range, _window, cx| {
|
||||
this.render_community_rows(range, rows.as_slice(), &community, cx)
|
||||
}),
|
||||
)
|
||||
.track_scroll(&self.community_scroll)
|
||||
.h_full()
|
||||
.px_2(),
|
||||
)
|
||||
.child(Scrollbar::vertical(&self.community_scroll)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_community_rows(
|
||||
&self,
|
||||
range: Range<usize>,
|
||||
rows: &[CommunityRow],
|
||||
community: &Entity<Community>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Vec<AnyElement> {
|
||||
rows.get(range)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|row| match row {
|
||||
CommunityRow::Section(section) => self.section_row(section, cx),
|
||||
CommunityRow::Member { public_key } => self.member_row(public_key, cx),
|
||||
CommunityRow::Channel {
|
||||
id,
|
||||
name,
|
||||
private,
|
||||
selected,
|
||||
} => self.channel_row(*id, name.clone(), *private, *selected, community, cx),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn rows_for(&self, tab: SidebarTab, cx: &App) -> Vec<SidebarRow> {
|
||||
match tab {
|
||||
SidebarTab::Inbox if self.contacts_open => self
|
||||
@@ -814,14 +745,14 @@ impl Sidebar {
|
||||
.map(|room| SidebarRow::Room { room })
|
||||
.collect()
|
||||
}
|
||||
SidebarTab::Communities => {
|
||||
let registry = CommunityRegistry::global(cx);
|
||||
SidebarTab::Groups => {
|
||||
let registry = GroupsRegistry::global(cx);
|
||||
registry
|
||||
.read(cx)
|
||||
.communities()
|
||||
.groups()
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|community| SidebarRow::Community { community })
|
||||
.map(|group| SidebarRow::Group { group })
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -929,17 +860,28 @@ impl Sidebar {
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
SidebarRow::Community { community } => {
|
||||
let name = community.read(cx).name();
|
||||
let seed = community.read(cx).id().to_hex();
|
||||
let picture = community.read(cx).icon();
|
||||
SidebarRow::Group { group } => {
|
||||
let dock = self.dock.clone();
|
||||
let sidebar = cx.entity().downgrade();
|
||||
let community = community.clone();
|
||||
let group = group.clone();
|
||||
|
||||
let name = group.read(cx).display_name();
|
||||
let picture = group.read(cx).display_image();
|
||||
let seed = group.read(cx).key().cache_tag();
|
||||
|
||||
let suffix = match group.read(cx).membership() {
|
||||
Membership::Pending { .. } => Some("Requested"),
|
||||
Membership::Refused { .. } => Some("Refused"),
|
||||
Membership::Removed => Some("Removed"),
|
||||
Membership::Unknown | Membership::Member => None,
|
||||
}
|
||||
.or_else(|| group.read(cx).elsewhere().map(|_| "Moved"))
|
||||
.map(SharedString::from);
|
||||
|
||||
Nav::new(index)
|
||||
.label(name)
|
||||
.text_sm()
|
||||
.font_medium()
|
||||
.when(!hide_avatar, |this| {
|
||||
this.prefix(
|
||||
Avatar::from_source(picture)
|
||||
@@ -948,24 +890,28 @@ impl Sidebar {
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
})
|
||||
.when_some(suffix, |this, suffix| {
|
||||
this.suffix(
|
||||
div()
|
||||
.font_normal()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(suffix),
|
||||
)
|
||||
})
|
||||
.on_click(move |_event, window, cx| {
|
||||
ui::dock::add_panel_to(
|
||||
&dock,
|
||||
PanelHandle::new(community_ui::init(
|
||||
community.clone(),
|
||||
window,
|
||||
cx,
|
||||
)),
|
||||
PanelHandle::new(nip29_ui::init(group.clone(), window, cx)),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
|
||||
if let Err(error) = sidebar.update(cx, |this, cx| {
|
||||
this.community = Some(community.downgrade());
|
||||
cx.notify();
|
||||
this.show_group(&group, cx);
|
||||
}) {
|
||||
log::error!("Failed to show community in sidebar: {error}");
|
||||
log::error!("Failed to show the group in the sidebar: {error}");
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
@@ -975,84 +921,243 @@ impl Sidebar {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn section_row(&self, section: &CommunitySection, cx: &mut Context<Sidebar>) -> AnyElement {
|
||||
let section = *section;
|
||||
let (label, open) = match section {
|
||||
CommunitySection::Channels => ("Channels", self.channels_open),
|
||||
CommunitySection::Admins => ("Admins", self.admins_open),
|
||||
CommunitySection::Members => ("Members", self.members_open),
|
||||
fn render_group(&mut self, group: Entity<Group>, cx: &mut Context<Self>) -> AnyElement {
|
||||
let admin = NostrRegistry::global(cx)
|
||||
.read(cx)
|
||||
.current_user()
|
||||
.is_some_and(|me| group.read(cx).admins().contains(&me));
|
||||
|
||||
let (name, seed, picture, total_members, rows) = {
|
||||
let group = group.read(cx);
|
||||
let admins = group.admins();
|
||||
|
||||
let mut admin_rows = Vec::new();
|
||||
let mut member_rows = Vec::new();
|
||||
|
||||
for (public_key, roles) in admins.entries() {
|
||||
admin_rows.push(GroupRow::Member {
|
||||
public_key: *public_key,
|
||||
roles: roles.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
for public_key in group.members().iter() {
|
||||
if admins.contains(public_key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
member_rows.push(GroupRow::Member {
|
||||
public_key: *public_key,
|
||||
roles: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut rows = Vec::new();
|
||||
|
||||
if !admin_rows.is_empty() {
|
||||
rows.push(GroupRow::Section(GroupSection::Admins));
|
||||
if self.admins_open {
|
||||
rows.append(&mut admin_rows);
|
||||
}
|
||||
}
|
||||
|
||||
if !member_rows.is_empty() {
|
||||
rows.push(GroupRow::Section(GroupSection::Members));
|
||||
if self.members_open {
|
||||
rows.append(&mut member_rows);
|
||||
}
|
||||
}
|
||||
|
||||
(
|
||||
group.display_name(),
|
||||
group.key().cache_tag(),
|
||||
group.display_image(),
|
||||
SharedString::from(format!("{} members", group.total_members())),
|
||||
rows,
|
||||
)
|
||||
};
|
||||
|
||||
let rows = Rc::new(rows);
|
||||
let member_group = group.downgrade();
|
||||
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_h_0()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.px_2()
|
||||
.flex_shrink_0()
|
||||
.justify_between()
|
||||
.child(
|
||||
h_flex()
|
||||
.pl_0p5()
|
||||
.gap_1()
|
||||
.flex_shrink_1()
|
||||
.child(Avatar::from_source(picture).seed(seed).small())
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.truncate()
|
||||
.line_height(relative(1.2))
|
||||
.font_semibold()
|
||||
.child(name),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(px(10.))
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(total_members),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(nip29_ui::actions(group.downgrade(), cx)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.min_h_0()
|
||||
.flex_1()
|
||||
.child(
|
||||
uniform_list(
|
||||
"group-rows",
|
||||
rows.len(),
|
||||
cx.processor(move |this, range, _window, cx| {
|
||||
this.render_group_rows(
|
||||
range,
|
||||
rows.as_slice(),
|
||||
&member_group,
|
||||
admin,
|
||||
cx,
|
||||
)
|
||||
}),
|
||||
)
|
||||
.track_scroll(&self.group_scroll)
|
||||
.h_full()
|
||||
.px_2(),
|
||||
)
|
||||
.child(Scrollbar::vertical(&self.group_scroll)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_group_rows(
|
||||
&self,
|
||||
range: Range<usize>,
|
||||
rows: &[GroupRow],
|
||||
group: &WeakEntity<Group>,
|
||||
admin: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Vec<AnyElement> {
|
||||
rows.get(range)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|row| match row {
|
||||
GroupRow::Section(section) => self.section_row(*section, cx),
|
||||
GroupRow::Member { public_key, roles } => {
|
||||
self.member_row(public_key, roles, group, admin, cx)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn section_row(&self, section: GroupSection, cx: &mut Context<Self>) -> AnyElement {
|
||||
let open = match section {
|
||||
GroupSection::Admins => self.admins_open,
|
||||
GroupSection::Members => self.members_open,
|
||||
};
|
||||
let icon = if open {
|
||||
IconName::CaretDown
|
||||
} else {
|
||||
IconName::CaretRight
|
||||
};
|
||||
let label = section.label();
|
||||
|
||||
Nav::new(label)
|
||||
.label(label)
|
||||
.suffix(nav_icon(icon, cx))
|
||||
.suffix(Icon::new(icon).small().text_color(cx.theme().icon_muted))
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||
match section {
|
||||
CommunitySection::Channels => this.channels_open = !this.channels_open,
|
||||
CommunitySection::Admins => this.admins_open = !this.admins_open,
|
||||
CommunitySection::Members => this.members_open = !this.members_open,
|
||||
GroupSection::Admins => this.admins_open = !this.admins_open,
|
||||
GroupSection::Members => this.members_open = !this.members_open,
|
||||
}
|
||||
cx.notify();
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn channel_row(
|
||||
fn member_row(
|
||||
&self,
|
||||
id: ChannelId,
|
||||
name: SharedString,
|
||||
private: bool,
|
||||
selected: bool,
|
||||
community: &Entity<Community>,
|
||||
cx: &mut Context<Sidebar>,
|
||||
public_key: &PublicKey,
|
||||
roles: &[String],
|
||||
group: &WeakEntity<Group>,
|
||||
admin: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let community = community.clone();
|
||||
let icon = if private {
|
||||
IconName::Lock
|
||||
} else {
|
||||
IconName::Hashtag
|
||||
};
|
||||
|
||||
Nav::new(id.to_hex())
|
||||
.label(name)
|
||||
.prefix(nav_icon(icon, cx))
|
||||
.text_sm()
|
||||
.font_medium()
|
||||
.selected(selected)
|
||||
.on_click(cx.listener(move |_this, _event, _window, cx| {
|
||||
community.update(cx, |community, cx| {
|
||||
community.set_active_channel(id, cx);
|
||||
});
|
||||
cx.notify();
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn member_row(&self, public_key: &PublicKey, cx: &App) -> AnyElement {
|
||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let person = persons.read(cx).get(public_key, cx);
|
||||
let public_key = *public_key;
|
||||
|
||||
let group = group.clone();
|
||||
|
||||
Nav::new(public_key.to_hex())
|
||||
.label(person.name())
|
||||
.text_sm()
|
||||
.font_medium()
|
||||
.clickable(true)
|
||||
.when(!hide_avatar, |this| {
|
||||
this.prefix(
|
||||
Avatar::from_source(person.avatar())
|
||||
Avatar::new(person.avatar())
|
||||
.seed(person.avatar_seed())
|
||||
.small()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
})
|
||||
.when(!roles.is_empty(), |this| {
|
||||
this.suffix(
|
||||
div()
|
||||
.truncate()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(SharedString::from(roles.join(", "))),
|
||||
)
|
||||
})
|
||||
.dropdown_menu(move |menu, _window, _cx| {
|
||||
let menu = menu
|
||||
.item(
|
||||
PopupMenuItem::new("View profile").on_click(move |_, window, cx| {
|
||||
profile::open(public_key, window, cx);
|
||||
}),
|
||||
)
|
||||
.item(PopupMenuItem::new("Copy").on_click(move |_, _, cx| {
|
||||
let npub = public_key.to_bech32().unwrap_or_default();
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(npub));
|
||||
}));
|
||||
|
||||
if !admin {
|
||||
return menu;
|
||||
}
|
||||
|
||||
menu.separator()
|
||||
.item(PopupMenuItem::new("Remove").on_click({
|
||||
let group = group.clone();
|
||||
move |_event, window, cx| {
|
||||
nip29_ui::remove_member(group.clone(), public_key, window, cx);
|
||||
}
|
||||
}))
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -1092,10 +1197,7 @@ impl Render for Sidebar {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let loading = chat.read(cx).loading();
|
||||
|
||||
let community = self
|
||||
.community
|
||||
.clone()
|
||||
.and_then(|community| community.upgrade());
|
||||
let group = self.group.clone().and_then(|group| group.upgrade());
|
||||
|
||||
v_flex()
|
||||
.image_cache(retain_all("sidebar"))
|
||||
@@ -1157,9 +1259,9 @@ impl Render for Sidebar {
|
||||
),
|
||||
)
|
||||
})
|
||||
.map(|this| match community {
|
||||
Some(community) => this.child(self.render_community(community, cx)),
|
||||
None => this.child(self.render_tabs(cx)),
|
||||
.map(|this| match group {
|
||||
Some(group) if self.group_open => this.child(self.render_group(group, cx)),
|
||||
_ => this.child(self.render_tabs(cx)),
|
||||
})
|
||||
.when(loading && logged_in, |this| {
|
||||
this.child(
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SidebarTab {
|
||||
Inbox,
|
||||
Communities,
|
||||
Groups,
|
||||
}
|
||||
|
||||
impl SidebarTab {
|
||||
pub const ALL: [SidebarTab; 2] = [Self::Inbox, Self::Communities];
|
||||
pub const ALL: [SidebarTab; 2] = [Self::Inbox, Self::Groups];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Inbox => "Inbox",
|
||||
Self::Communities => "Communities",
|
||||
Self::Groups => "Groups",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,21 +18,21 @@ impl SidebarTab {
|
||||
pub fn list_title(self) -> &'static str {
|
||||
match self {
|
||||
Self::Inbox => "Direct Messages",
|
||||
Self::Communities => "Communities",
|
||||
Self::Groups => "Groups",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Inbox => "sidebar-inbox",
|
||||
Self::Communities => "sidebar-communities",
|
||||
Self::Groups => "sidebar-groups",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn index(self) -> usize {
|
||||
match self {
|
||||
Self::Inbox => 0,
|
||||
Self::Communities => 1,
|
||||
Self::Groups => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, px};
|
||||
use theme::ActiveTheme;
|
||||
use ui::{Icon, IconName, Sizable, h_flex};
|
||||
|
||||
pub(crate) fn nav_icon(icon: IconName, cx: &App) -> AnyElement {
|
||||
h_flex()
|
||||
.flex_shrink_0()
|
||||
.w(px(20.))
|
||||
.justify_center()
|
||||
.text_color(cx.theme().icon_muted)
|
||||
.child(Icon::new(icon).small())
|
||||
.into_any_element()
|
||||
}
|
||||
use gpui::SharedString;
|
||||
|
||||
/// Brand backgrounds shown behind the signed-out screen; one is picked at random.
|
||||
const SIGNED_OUT_BANNERS: [&str; 2] = ["brand/bg1.jpg", "brand/bg2.jpg"];
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ common = { path = "../crates/common" }
|
||||
state = { path = "../crates/state" }
|
||||
device = { path = "../crates/device" }
|
||||
chat = { path = "../crates/chat" }
|
||||
community = { path = "../crates/community" }
|
||||
nip29 = { path = "../crates/nip29" }
|
||||
settings = { path = "../crates/settings" }
|
||||
auto_update = { path = "../crates/auto_update" }
|
||||
person = { path = "../crates/person" }
|
||||
|
||||
+2
-2
@@ -78,8 +78,8 @@ fn main() {
|
||||
// Initialize app registry
|
||||
chat::init(cx);
|
||||
|
||||
// Initialize community registry
|
||||
community::init(cx);
|
||||
// Initialize groups registry
|
||||
nip29::init(cx);
|
||||
|
||||
// Initialize auto update
|
||||
auto_update::init(cx);
|
||||
|
||||
@@ -1,780 +0,0 @@
|
||||
# Using the Concord backend
|
||||
|
||||
`crates/concord` is a protocol crate: derivations, envelopes, folds and the local
|
||||
state document. It has no GPUI dependency and owns no strings a user reads — the
|
||||
UI layer decides every rendering. This document is the map from a UI action to
|
||||
the calls it makes.
|
||||
|
||||
A community is addressed by a `community_id` (never on the wire) plus three
|
||||
secrets: `community_root` (read access — holding it *is* membership),
|
||||
`control_root` (write access to the Control Plane, held by staff), and per-Channel
|
||||
keys for private channels. Authority is a roster of owner-rooted signed grants,
|
||||
folded independently by every client.
|
||||
|
||||
## Modules
|
||||
|
||||
Files follow the CORD documents. The frozen derivations of Appendix A and the id
|
||||
vocabulary are shared substrate — every document calls them — so they live outside
|
||||
`cords` in `utils::derive` (re-exported as `derive`) and `types`.
|
||||
|
||||
| Module | Owns |
|
||||
| --- | --- |
|
||||
| `cord01` | Private Streams: the seal/wrap envelope and the NIP-44 helpers |
|
||||
| `cord02` | Communities: identity, epochs, metadata, the Control Plane fold and writer |
|
||||
| `cord02::guestbook` | Joins, leaves, kicks, snapshots, the member list |
|
||||
| `cord02::list` | The Community List (a member's own memberships, across devices) |
|
||||
| `cord03` | Channels: Channel metadata and the Chat Plane |
|
||||
| `cord04` | Roles: chained editions, parse/hash/fold, the roster, permissions, the banlist |
|
||||
| `cord04::pins` | Pin Lists, and the key disclosure a keyless reader verifies |
|
||||
| `cord05` | Invite bundles, links, the Direct Invite, the Invite List |
|
||||
| `cord06` | Key rotations, refounding, compaction, dissolution |
|
||||
| `derive` | Every frozen HKDF derivation and coordinate |
|
||||
| `state` | The community state document and its pure folds — no I/O |
|
||||
|
||||
`CommunityId`, `ChannelId`, `RoleId`, `Epoch` and `Extra` (crate-internal) come from
|
||||
the private `types` module and are re-exported at the crate root.
|
||||
|
||||
Nothing in `concord` touches a relay or a database. The local rumor cache, the
|
||||
state documents and the relay paging walk live one crate up, in
|
||||
`community::cache` and `community::history`, and are what a client actually calls
|
||||
(read on below).
|
||||
|
||||
CORD-07 (audio/video) is unimplemented. CORD-08's timer has no file of its own: it
|
||||
lives in the metadata it reads (`cord02`) and the fold it filters (`cord03`).
|
||||
|
||||
Read `CommunityId` as "this community", `ChannelId` as "this channel", `Epoch` as
|
||||
"which key generation". Nothing else in the API needs internal state.
|
||||
|
||||
## Creating a community
|
||||
|
||||
```rust
|
||||
use concord::cord02::{self, CommunityMetadata};
|
||||
use concord::state::CommunityState;
|
||||
use community::cache::save_state;
|
||||
|
||||
let metadata = CommunityMetadata { name: "Room".into(), ..Default::default() };
|
||||
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
|
||||
// minted.channel_id — the #general channel
|
||||
for wrap in &minted.wraps {
|
||||
client.send_event(wrap).to(&relays).await?;
|
||||
}
|
||||
```
|
||||
|
||||
The owner then needs the folded state, which is also what every member does on
|
||||
join:
|
||||
|
||||
```rust
|
||||
use concord::derive::{control_group_key, control_signer_group_key};
|
||||
use concord::cord04::ParsedEdition;
|
||||
|
||||
let read = control_group_key(&minted.community_root, &minted.identity.community_id, Epoch(0))?;
|
||||
let signer = control_signer_group_key(&minted.control_root, &minted.identity.community_id, Epoch(0))?;
|
||||
let editions: Vec<ParsedEdition> = minted
|
||||
.wraps
|
||||
.iter()
|
||||
.map(|wrap| cord02::open_edition(wrap, &read, &signer.pk(), true))
|
||||
.collect::<Result<_, _>>()?;
|
||||
|
||||
let mut state = CommunityState::from_genesis(&minted, &editions, added_at_ms)?;
|
||||
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:
|
||||
|
||||
```rust
|
||||
use concord::cord05::{self, BundleState, invite_bundle_key};
|
||||
|
||||
let link = cord05::parse_link(url)?; // link_signer, token, bootstrap_relays, naddr
|
||||
|
||||
// The crate does no I/O: fetch the naddr from the fragment's relays, then:
|
||||
let invite = match cord05::parse_bundle_event(&event, &link.link_signer, &invite_bundle_key(&link.token))? {
|
||||
BundleState::Live(invite) => invite, // validate() already ran
|
||||
BundleState::Revoked => return Ok(None), // a tombstone at the coordinate
|
||||
};
|
||||
```
|
||||
|
||||
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).await?;
|
||||
```
|
||||
|
||||
Either way the invite carries `community_id`, `owner`, `owner_salt`,
|
||||
`community_root`, `root_epoch`, `control_pk`, the granted `channels`
|
||||
(`ChannelGrant { id, key, epoch, name }`) and the relay set. `invite.expired(now_ms)`
|
||||
is a preview rule: a past expiry still renders, but the join is refused.
|
||||
|
||||
Then publish a join so the member list sees the member before any backfill:
|
||||
|
||||
```rust
|
||||
use concord::derive::guestbook_group_key;
|
||||
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).await?;
|
||||
client.send_event(&wrap).to(&relays).await?;
|
||||
```
|
||||
|
||||
## Reading the Control Plane
|
||||
|
||||
```rust
|
||||
use concord::cord02::{self, ControlFold};
|
||||
|
||||
let editions: Vec<ParsedEdition> = wraps
|
||||
.iter()
|
||||
.filter_map(|wrap| cord02::open_edition(wrap, &read, &control_pk, true).ok())
|
||||
.collect();
|
||||
|
||||
let control: ControlFold =
|
||||
cord02::fold_control(&owner, &community_id, &editions, &state.floors(), &state.banned);
|
||||
state.apply_fold(&control);
|
||||
```
|
||||
|
||||
`ControlFold` is everything the community UI needs:
|
||||
|
||||
| Field | Use |
|
||||
| --- | --- |
|
||||
| `community` | name, description, icon, banner, `message_expiration` |
|
||||
| `channels` | the channel list; `deleted: true` means drop it |
|
||||
| `roles` | `role()`, `roles_of()`, `effective_permissions()`, `is_authorized()`, `is_staff()` |
|
||||
| `banned` | the banlist |
|
||||
| `registries` / `is_public()` | each invite creator's live link signers |
|
||||
| `pins` / `pin_content(id, channel)` | Pin List content per channel |
|
||||
| `floors` | the committed heads the next fold is judged against |
|
||||
| `gapped` | a chain hole: refetch the Control Plane before trusting what is missing |
|
||||
|
||||
`None` on `community` or a channel means "this client saw no authorized edition",
|
||||
never "the value is gone" — keep what the state already holds rather than walking
|
||||
the community backwards. Feed `state.floors()` and `state.banned` into the next
|
||||
fold; they are its memory.
|
||||
|
||||
## Sending a message
|
||||
|
||||
```rust
|
||||
use concord::cord03::{self, build_message};
|
||||
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).await?;
|
||||
client.send_event(&wrap).to(&relays).await?;
|
||||
```
|
||||
|
||||
- `epoch` is the **root** epoch for a public channel: its plane is
|
||||
`channel_group_key(community_root, channel, root_epoch)`, so a Refounding moves
|
||||
it along with the root. A private channel passes its own current channel epoch
|
||||
(`state.channels` carries it) and derives from its own key instead of
|
||||
`community_root`.
|
||||
- `timer` is `control.community.message_expiration`; pass `None` when it is off.
|
||||
The builder attaches the NIP-40 tag and `seal_rumor` mirrors it onto the wrap,
|
||||
so relays drop the ciphertext too.
|
||||
- `ephemeral: true` picks kind `21059` for typing indicators. Keep the returned
|
||||
wrap key if the message may be deleted later — a kind-5 delete needs it.
|
||||
- That `send_event` is the whole publish path; there is no optimistic echo. Feed
|
||||
the wrap through the same ingest path the subscription uses so send-then-read
|
||||
never waits on a relay round trip.
|
||||
|
||||
`build_edit`, `build_reaction` and `build_delete` are the same shape, each a rumor
|
||||
about an existing `EventId` rather than a mutation.
|
||||
|
||||
## Reading a channel
|
||||
|
||||
```rust
|
||||
use concord::cord03::{self, fold, plane_keys};
|
||||
use community::cache;
|
||||
|
||||
let planes = plane_keys(&held, &channel)?; // &[(Epoch, secret)]
|
||||
let mut rumors = Vec::new();
|
||||
|
||||
for wrap in &wraps {
|
||||
let Some((epoch, group)) = planes.iter().find(|(_, group)| group.pk() == wrap.pubkey) else {
|
||||
continue;
|
||||
};
|
||||
let Ok((opened, rumor)) = cord03::open(wrap, group, &channel, *epoch) else {
|
||||
continue;
|
||||
};
|
||||
cache::cache_rumor(&client, &channel, &opened).await?;
|
||||
rumors.push(rumor);
|
||||
}
|
||||
|
||||
let messages = fold(&rumors, Timestamp::now(), |actor, citation, author| {
|
||||
citation_ok(&owner, &community_id, actor, citation, &control.roles.floors)
|
||||
&& control.roles.can_act_on_member(actor, &owner, author, Permissions::MANAGE_MESSAGES)
|
||||
});
|
||||
```
|
||||
|
||||
`ChatMessage` carries `content`, `at_ms`, `edited_at`, `deleted`, `reactions`,
|
||||
`reply_to` and `thread_root` already resolved. The fold drops expired rumors; a
|
||||
`deleted` row is still returned so the timeline keeps its shape.
|
||||
|
||||
Relay history pages through the local cache:
|
||||
|
||||
```rust
|
||||
use community::history::{self, PageRegistry, Window};
|
||||
|
||||
// Every key the client still holds for the channel, newest epoch first: the
|
||||
// state's current key (`ChannelKeyRef::key` at `epoch`) plus every `priors`
|
||||
// entry a rotation stepped off. A public channel derives one per held root.
|
||||
let held: Vec<concord::state::HeldKey> = state.held_keys(&channel);
|
||||
|
||||
// The same registry `CommunityRegistry` shares with its notification pump: it is
|
||||
// what carries each page's EOSE and CLOSED back to the round waiting on it.
|
||||
let page = history::page(
|
||||
client,
|
||||
&pages,
|
||||
&channel,
|
||||
&held,
|
||||
&relays,
|
||||
Window::opening(cursor),
|
||||
20,
|
||||
50,
|
||||
)
|
||||
.await?;
|
||||
let cached = cache::query_rumors(&client, &channel, None, 50, Some(&cord03::ROW_KINDS)).await?;
|
||||
```
|
||||
|
||||
A key's `retired_at` (a `Timestamp`, set when a rotation supersedes it) is a read
|
||||
cutoff: a wrap at that epoch with a later `created_at` is refused, so a retired
|
||||
epoch is history and never a live plane an ejected holder can keep writing into.
|
||||
|
||||
A relay is only ever *asked*: `history::page` installs **one REQ per page** over
|
||||
`ReqTarget::manual` for the community's own relays (which is what makes the client
|
||||
verify a wrap, deduplicate it and persist it), waits for every relay to settle,
|
||||
and then reads the page back out of the local database — no wrap is ever consumed
|
||||
straight off the wire, and `fetch_events` is not used anywhere. The wrap lands in
|
||||
the client's shared event store through that ordinary ingest path; `cache_rumor`
|
||||
then puts the decrypted rumor beside it, and only the rumor is ever served to a
|
||||
reader.
|
||||
|
||||
EOSE and CLOSED are **not** watched inside the page. The registry's notification
|
||||
pump is the only consumer of `client.notifications()`; it delivers one
|
||||
`PageReport` per settling relay into the `PageRegistry` under the page's
|
||||
subscription id, and the page waits on that channel with `PAGE_TIMEOUT`. The id
|
||||
is opaque (`concord-history-<n>`) because a NIP-01 id is capped at 64 characters,
|
||||
and the registry is what maps it back to the waiting round.
|
||||
|
||||
A page ends on EOSE. A CLOSED ends it as unanswered, *except* NIP-42's
|
||||
`auth-required`: the SDK re-issues that REQ under the same subscription id once
|
||||
the handshake completes, so the page waits for the resubscribed answer rather
|
||||
than writing off a relay that only wanted to authenticate.
|
||||
|
||||
`history::page` walks newest-first across every held epoch, caches what it opens, and
|
||||
reports what it saw: `oldest`/`newest` feed the caller's `ChannelCursor`,
|
||||
`exhausted` is earned only by a short page *after* history was seen, and an
|
||||
all-empty answer sets `failed` so a later round re-asks instead of sealing the
|
||||
channel at "no more history". `unreadable` counts the wraps the page reached that
|
||||
no held key could open — sealed past the cutoff their key's rotation set, or bound
|
||||
to another channel — because those are history the reader is missing, not history
|
||||
that is not there. Page down with `Window::older_than(seen.oldest)`,
|
||||
open a channel with `Window::opening(cursor)` (wide cold, `newest - 60s` warm),
|
||||
and read the region between two cursors with `Window::between(..)`. A `Window`
|
||||
carries `Timestamp`s, so its bounds go straight into a NIP-01 filter with no unit
|
||||
conversion; only a reader's millisecond `at_ms` narrows to a second at the query.
|
||||
`query_rumors` is the read path when the group keys are gone; pass `kinds` to
|
||||
budget rows apart from the events that only decorate them. `cache::wrapper_index`
|
||||
reads the cached rows back keyed by the wrap they came from, which is what lets
|
||||
`sync::fold` observe author and message times without re-opening a wrap it already
|
||||
cached, and `cache::purge_expired(client, &channel, now)` runs at the top of every
|
||||
round — the timer is cooperative, so the local store is the artifact that has to
|
||||
forget.
|
||||
|
||||
`sync::fold` counts the same thing over the whole store, per channel, in
|
||||
`Snapshot.unreadable`. `sync_round` sums a round's pages into
|
||||
`Progress.unreadable`, and `Community` keeps both per channel: `progress(channel)`
|
||||
is the last completed round, `unreadable(channel)` is the count, and
|
||||
`missing_key(channel)`, `channel_removed_at(channel)`, `removed_at()` and
|
||||
`stranded()` are the rest of the honest read surface a panel needs to tell an
|
||||
empty room from one it cannot read. `Community::due(channel)` is the other half —
|
||||
whether a round is worth asking for yet (a round for `Older` and an explicit retry
|
||||
never ask it), and `Community::tick`, driven by `CommunityRegistry` once per
|
||||
`community::MIN_ROUND_INTERVAL`, re-folds and re-rounds the active channel of a
|
||||
community whose last round is older than `STALE_AFTER`.
|
||||
|
||||
`ChatAction::TimerNotice { seconds }` is a policy notice, not a message: render it
|
||||
as an inline row only when its author passes
|
||||
`control.roles.is_authorized(&author, &owner, Permissions::MANAGE_METADATA)`.
|
||||
|
||||
## Membership
|
||||
|
||||
```rust
|
||||
let states = cord02::guestbook::coalesce(&rumors, now_ms, &refounders, |actor, target, citation| {
|
||||
citation_ok(&owner, &community_id, actor, citation, &control.roles.floors)
|
||||
&& control.roles.can_act_on_member(actor, &owner, target, Permissions::KICK)
|
||||
});
|
||||
let members = cord02::guestbook::complete_memberlist(&states, &observed, &granted, &control.banned, &BTreeMap::new());
|
||||
```
|
||||
|
||||
- `refounders` is the set of npubs whose rotations minted an epoch this client
|
||||
verified (`CommunityState.refounders`) — a snapshot chunk is honored only from
|
||||
one of them, and an empty set honors none. A rotation that delivered this
|
||||
client no key still names its minter, so the set is filled by any base rotation
|
||||
whose continuity verifies against a root held (`rekey::walk`); a List entry's
|
||||
`refounder`, read by `list::JoinMaterial::refounder`, is the other source.
|
||||
- `observed` is npub → ms for every author this client has seen publish anything
|
||||
usable, which is what makes a member visible before their Join arrives. Only
|
||||
count it forward.
|
||||
- `granted` is every npub the roster ranks; they are members with no Guestbook
|
||||
entry at all.
|
||||
- `banned_at` is empty today, so a ban is terminal in the fold. Fill it when the
|
||||
banlist head's timestamp is plumbed through.
|
||||
- Removal is three separate actions, composed by the caller: strip the grant
|
||||
(immediate and cheap), then the kick directive, then — for a ban — the rotation
|
||||
that actually enforces it.
|
||||
|
||||
## Moderation writes
|
||||
|
||||
Every Control Plane write goes through one writer and one edition shape:
|
||||
|
||||
```rust
|
||||
let writer = ControlWriter { author: my_pk, read: read.clone(), signer: signer.clone() };
|
||||
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).await?;
|
||||
```
|
||||
|
||||
`citation` is the `vac` the actor acts under — `None` only for the owner. Build it
|
||||
from the folded Grant that ranks them (`AuthorityCitation { entity, version, hash }`)
|
||||
and pass the head from the current fold, so the chain cannot silently fork.
|
||||
|
||||
Wrappers: `set_community_metadata`, `set_channel_metadata`, `set_role`,
|
||||
`set_grant`, `set_banlist`, `set_registry`, `set_pin_list`, plus raw `publish`.
|
||||
A ban is a `set_banlist` followed by a base rekey; a kick is a `set_grant` with an
|
||||
empty `role_ids` followed by `cord02::guestbook::build_kick`.
|
||||
|
||||
## Pins
|
||||
|
||||
```rust
|
||||
use concord::cord04::pins;
|
||||
|
||||
let entry = cord04::pins::build_entry(&opened_message, &plane, &channel)?;
|
||||
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).await?;
|
||||
```
|
||||
|
||||
Reading is verification: `read_list` decodes either content form (public, or
|
||||
sealed under the channel key at the named epoch), and
|
||||
`cord04::pins::verify_entry(entry, &channel)` returns a `VerifiedPin` with the proven
|
||||
author, words and time — no history and no old keys needed. `read.sealed` means
|
||||
the list is sealed under an epoch this client never held: show it as unavailable,
|
||||
and never write from it (`publishable` refuses). `cord04::pins::killed_by(&pin, &delete)`
|
||||
answers whether a folded kind-5 erases an entry.
|
||||
|
||||
## Invites
|
||||
|
||||
```rust
|
||||
use concord::derive::{invite_bundle_key, TOKEN_LEN};
|
||||
use concord::cord05::{self, InviteEntry, InviteTombstone};
|
||||
|
||||
let token: [u8; TOKEN_LEN] = /* 16 bytes from any CSPRNG */;
|
||||
let bundle_key = invite_bundle_key(&token);
|
||||
let link_signer = Keys::generate();
|
||||
let bundle = cord05::build_bundle_event(&link_signer, &invite, &bundle_key)?;
|
||||
let url = cord05::build_invite_url(BASE, &link_signer.public_key(), &token, &relays)?;
|
||||
```
|
||||
|
||||
A link is a coordinate plus a fragment: the naddr fetches the bundle, the token
|
||||
unlocks it, and the fragment names the relays to fetch from.
|
||||
`cord05::stock_relays()` is what a fragment with no relays of its own means.
|
||||
|
||||
The `link_signer` secret is what lets the creator refresh or retire the link, so
|
||||
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).await?;
|
||||
list.entries.push(InviteEntry {
|
||||
token: HEXLOWER.encode(&token),
|
||||
signer_sk: link_signer.secret_key().to_secret_hex(),
|
||||
community_id: invite.community_id,
|
||||
url,
|
||||
label: None,
|
||||
created_at: now_ms,
|
||||
expires_at: None,
|
||||
extra: Default::default(),
|
||||
});
|
||||
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 {
|
||||
token: HEXLOWER.encode(&token),
|
||||
community_id: invite.community_id,
|
||||
extra: Default::default(),
|
||||
});
|
||||
```
|
||||
|
||||
`merge_invite_lists` merges two devices' copies, `is_live(&token_hex)` answers
|
||||
whether a link still stands, and `fits()` is the write gate.
|
||||
|
||||
## Rekeys, refounding and dissolution
|
||||
|
||||
A rotation is authority plus delivery: `rekey_authorized(&control.roles, &owner, &me, permission, &removed)`
|
||||
gates it, `plan_rotation(scope, epoch)` mints what it delivers, and
|
||||
`build_rekey_chunks` seals one blob per remaining member:
|
||||
|
||||
```rust
|
||||
use concord::derive::epoch_key_commitment;
|
||||
use concord::cord06::{self, RekeyScope, RotationPlan};
|
||||
|
||||
let scope = RekeyScope::Channel(channel_id); // or RekeyScope::Base
|
||||
let plan = cord06::plan_rotation(scope, Epoch(epoch + 1))?;
|
||||
let new_key = plan.new_key();
|
||||
|
||||
// A base rotation delivers the new control-plane keys beside the root; a channel
|
||||
// rotation delivers only that channel's fresh key.
|
||||
let (control_pk, control_root) = match &plan {
|
||||
RotationPlan::Base(refounding) => {
|
||||
let pk = refounding.signer(&community_id)?.pk().to_bytes();
|
||||
(Some(pk), is_staff.then_some(&refounding.new_control_root))
|
||||
}
|
||||
RotationPlan::Channel { .. } => (None, None),
|
||||
};
|
||||
|
||||
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(
|
||||
&my_keys,
|
||||
&rekey_group,
|
||||
scope,
|
||||
plan.epoch(),
|
||||
Epoch(epoch),
|
||||
&epoch_key_commitment(Epoch(epoch), &community_root),
|
||||
&blobs,
|
||||
citation,
|
||||
false,
|
||||
now_secs,
|
||||
)
|
||||
.await?;
|
||||
```
|
||||
|
||||
On the receiving side, `cord06::parse_rekey_chunk(&opened)` per wrap, then
|
||||
`collect_rotations(&chunks)`, then `am_i_removed(&rotation, &me)` — which is
|
||||
`None` until every chunk is held, because an incomplete set is never a removal. A
|
||||
member finds their delivery with `find_my_blobs` / `open_blob`, and adopts the key
|
||||
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`.
|
||||
|
||||
`community::rekey::adopt` is that receiver, run against the local database: it
|
||||
walks a scope forward one epoch at a time off the key actually held (never
|
||||
waiving a gap, never adopting a fork), keeps each stepped-off key as a prior
|
||||
with the rotation's publish time as its read cutoff, and reports a removal or a
|
||||
strand when a complete rotation carries no blob for the member.
|
||||
|
||||
`community::rekey::rotate` is the sender, run against the held state: it resolves
|
||||
the epoch and key the scope is stepping off, refuses a rotation that would skip or
|
||||
cut off its own rotator, delivers one blob per recipient (`Rewrite.recipients`,
|
||||
which the caller names because a private channel's audience is not in the local
|
||||
state), marks the rotation severed when it excludes somebody, and — for a base
|
||||
scope — carries the settled Control Plane heads onto the new epoch's groups with
|
||||
`cord06::compact`. `Community::rotate` checks `Rewrite::authorized` first, the same
|
||||
`rekey_authorized` under the same permissions `adopt` applies, publishes to the
|
||||
community's relays, and then adopts what it published through `rekey::adopt`
|
||||
rather than by construction: a rotation this client wrote and one it received take
|
||||
one path.
|
||||
|
||||
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).await?;
|
||||
|
||||
// A receiver seals the community read-only on sight.
|
||||
if cord06::verify_dissolved(&wrap, &identity) {
|
||||
state.dissolved = true;
|
||||
}
|
||||
```
|
||||
|
||||
## The Community List
|
||||
|
||||
A member's own memberships, synced across their devices:
|
||||
|
||||
```rust
|
||||
use concord::cord02::list;
|
||||
|
||||
let entry = concord::state::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; `concord::state::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.)
|
||||
|
||||
A join material may also carry two hex extensions this crate does not write but
|
||||
does read, because they are the only place a device that never held a rotation can
|
||||
learn who minted an epoch: `refounder` names the npub whose Refounding minted the
|
||||
entry's `root_epoch`, and `held_roots` is the retained prior epochs
|
||||
(`[{epoch, key, refounder?, control_pk?, retired_at?}]`). `JoinMaterial::refounder()`
|
||||
and `JoinMaterial::held_roots()` decode both; unknown fields are round-tripped
|
||||
regardless, so a document that carries them keeps them.
|
||||
|
||||
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 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 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.
|
||||
|
||||
### Entities
|
||||
|
||||
Same shape as `ChatRegistry`:
|
||||
|
||||
```rust
|
||||
pub fn init(cx: &mut App) {
|
||||
CommunityRegistry::set_global(cx.new(CommunityRegistry::new), cx);
|
||||
}
|
||||
|
||||
impl CommunityRegistry {
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalCommunityRegistry>().0.clone()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
- `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.
|
||||
- `CommunityState::apply_fold` is one assignment: run it in the task that
|
||||
produced the fold and send only the result to the foreground.
|
||||
- Emit an event on every fold so dependents re-read.
|
||||
|
||||
### Foreground and background
|
||||
|
||||
A background task never touches an entity. It sends results through a bounded
|
||||
`flume` channel that a foreground `cx.spawn` drains with `this.update(...)`.
|
||||
|
||||
```rust
|
||||
let (signal_tx, signal_rx) = flume::bounded::<Signal>(256);
|
||||
let client = client.clone();
|
||||
|
||||
// Background: open, verify, fold — no entities.
|
||||
self.ingress = Some(cx.background_spawn(async move {
|
||||
for wrap in &wraps {
|
||||
let Some(plane) = planes.iter().find(|plane| plane.group.pk() == wrap.pubkey) else {
|
||||
continue;
|
||||
};
|
||||
let (opened, rumor) = cord03::open(wrap, &plane.group, &plane.channel, plane.epoch)?;
|
||||
cache::cache_rumor(&client, &plane.channel, &opened).await?;
|
||||
signal_tx.send_async(Signal::Chat { channel: plane.channel, rumor }).await?;
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
|
||||
// Foreground: the only place entities change.
|
||||
self.consumer = Some(cx.spawn(async move |this, cx| {
|
||||
while let Ok(signal) = signal_rx.recv_async().await {
|
||||
this.update(cx, |this, cx| this.apply(signal, cx))?;
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
```
|
||||
|
||||
- Every cache 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.
|
||||
- `cx.spawn` when the work updates an entity after awaiting, and
|
||||
`cx.background_spawn` when it only produces a value. A query the foreground awaits can be returned straight out: `fn messages(&self, cx: &App) -> Task<Result<Vec<ChatMessage>, Error>>`.
|
||||
- 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. 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
|
||||
|
||||
A wrap is addressed to a plane, so the plane's public key is the routing key and
|
||||
one `Filter` per held plane is enough:
|
||||
|
||||
```rust
|
||||
let filter = Filter::new()
|
||||
.kinds([Kind::from(KIND_WRAP), Kind::from(KIND_WRAP_EPHEMERAL)])
|
||||
.pubkey(plane.group.pk())
|
||||
.since(joined_at);
|
||||
client.subscribe(filter).with_id(sub_id).await?;
|
||||
```
|
||||
|
||||
- `pubkeys([...])` carries every plane of a community on one subscription. Call
|
||||
`subscribe` again with the new address whenever a join, a channel add or a
|
||||
rekey fold changes it.
|
||||
- Route inbound events by `subscription_id` from `RelayMessage::Event`, never by
|
||||
kind.
|
||||
- Watch one epoch ahead for the base, and a **window** of channel epochs under
|
||||
every held root for private channels: `base_rekey_group_key(&root_N, &id,
|
||||
Epoch(N + 1))`, plus `channel_rekey_group_key(&root, &channel, Epoch(channel_epoch
|
||||
+ ahead))` for `ahead in 1..=8` and each root in `state.roots()`. A Refounding
|
||||
seals its channel rekeys under the root current when it was minted, so a member
|
||||
who adopted the base rotation first must still ask under the prior root; the
|
||||
window is what lets a member who missed a rotation catch up, or learn they were
|
||||
cut. This is `community::rekey::watches`, installed as a second standing REQ
|
||||
whose id resolves back to its community through `rekey::WatchRegistry` (the id
|
||||
cannot carry a 64-hex community id within the NIP-01 length cap).
|
||||
- `community::rekey::adopt` then reads those wraps **from the database** and walks
|
||||
each scope forward one epoch at a time: a complete, authorized rotation whose
|
||||
`prevcommit` extends the key actually held hands over the next key (raced
|
||||
rotations settle on the lowest); a gap is fetched, never waived; a fork is never
|
||||
adopted. An addressed-but-unverifiable rotation is neither adopted nor read as a
|
||||
removal. Only a complete rotation at/after the join, from a rotator who outranks
|
||||
the member, with no blob for them, is a removal; one that predates the join is a
|
||||
strand — a stale invite landed the member on a superseded epoch.
|
||||
|
||||
### Tests
|
||||
|
||||
`cx.background_executor().timer(..)` for delays, never `smol::Timer`, or
|
||||
`run_until_parked()` finds nothing left to run. Push a wrap into the channel and
|
||||
`run_until_parked()` to drive the foreground consumer.
|
||||
|
||||
## Not wired up yet
|
||||
|
||||
- **`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`, `community::cache`) 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.
|
||||
- **Rotation-delivered plane keys are persisted, and history spans them.**
|
||||
`CommunityState.held_roots` keeps every root a rotation stepped off (with the
|
||||
retired root's Control signer and the publish time that retires it), and each
|
||||
`ChannelKeyRef.priors` keeps every channel key a rotation stepped off. The read
|
||||
side derives planes from all of them, and a retired key's `retired_at` is a hard
|
||||
read cutoff at both `history::page` and `sync::fold`. The state
|
||||
`Community::removed_at`/`Community::stranded`/`channel_removed_at` carries is
|
||||
rendered as a notice **and** enforced at write time: `Community::send` refuses
|
||||
when `channel_secret` is `None` (a removal, a strand, a channel cut, or a key we
|
||||
never held), because a wrap sealed under a superseded root would reach nobody who
|
||||
rotated. A rotation this client publishes itself (`Community::rotate`) is adopted
|
||||
through the same `rekey::adopt` an arriving one goes through, so the held state
|
||||
and the wire cannot diverge.
|
||||
@@ -0,0 +1,384 @@
|
||||
# NIP-29 migration plan
|
||||
|
||||
`crates/nip29` is complete and reviewed; `docs/nip29-usage.md` is its API contract. This
|
||||
plan replaces the Concord stack — `crates/concord`, `crates/community` and
|
||||
`crates/community_ui` — with it, and lays out the UI surfaces the backend was built for.
|
||||
|
||||
Every phase ends with the app building and running. The last phase deletes the old crates,
|
||||
so nothing is removed until the replacement is in place.
|
||||
|
||||
## What is being replaced
|
||||
|
||||
| Today | Replacement |
|
||||
| --- | --- |
|
||||
| `crates/concord` (`cord01`–`cord06`, `state`, `derive`) | deleted — the relay owns identity, membership, roles and history |
|
||||
| `crates/community` (`CommunityRegistry`, `Community`, `sync`, `cache`, `rekey`, `history`) | `crates/nip29` (`GroupsRegistry`, `Group`) — already built |
|
||||
| `crates/community_ui` (`CommunityPanel`) | new `crates/nip29_ui` (`GroupPanel`) |
|
||||
| Sidebar `Communities` tab + two-level community view (channels / admins / members) | one-level `Groups` tab; admins and members move into a details dialog |
|
||||
| `dialogs/new_community.rs` | `dialogs/new_group.rs` and a new `dialogs/join_group.rs` |
|
||||
| `panels/browse.rs` stub | a working browse panel over `GroupsRegistry::browse` |
|
||||
| `settings.recent_communities` (dead, nothing reads it) | removed |
|
||||
|
||||
## Capability disposition
|
||||
|
||||
| Concord capability | On NIP-29 |
|
||||
| --- | --- |
|
||||
| End-to-end encrypted channels | Dropped. NIP-29 groups are relay-readable; `private` only means members-only read. |
|
||||
| Channels inside a community | Dropped. A group is one timeline; subgroups are the only hierarchy. |
|
||||
| Roles and control plane | Relay owns them (`39001`, `39003`); the client reads and displays, never derives authority. |
|
||||
| Rekey, rotation, refounding, dissolution | Dropped with `cord06`; leaving and deleting are relay operations. |
|
||||
| Invites | `9009` `create-invite` + the `?invite=` suffix on a group `naddr`. |
|
||||
| Pins | `9010` → relay mirrors `39005`; the backend is ready. |
|
||||
| Message deletion | `9005`; the local eviction lands in Phase 0. |
|
||||
| Reactions | Kind `7` rows arrive as ordinary timeline rows; grouping them under the target is a later enhancement. |
|
||||
| Members list | `39002` when the relay publishes it; may be absent or partial by design. |
|
||||
| Community list | `10009`, owned entirely by the backend. |
|
||||
| Multi-relay communities | One relay per group; migration and forks surface through `Group::elsewhere()`. |
|
||||
| Attachments | Any kind the relay supports; v1 renders media URLs already parsed by `chat::Message`. |
|
||||
|
||||
## Rules the implementation keeps
|
||||
|
||||
Carried over from the backend review; they apply to every phase.
|
||||
|
||||
- New code goes into `crates/nip29` and `crates/nip29_ui`. Never create a `groups` crate.
|
||||
- Implementation-block style: methods on the type they belong to, `self` first.
|
||||
- Read model is subscribe → pump → envelope cache → hydrate. Never `fetch_events`.
|
||||
- Short-lived subscriptions close on EOSE (`SubscribeAutoCloseOptions`), long-lived ones use a
|
||||
prefix the pump recognises; dedup events by id before they mutate state.
|
||||
- I/O runs on `cx.background_spawn`, called from a foreground `cx.spawn` task; tasks are
|
||||
stored on the entity so they cancel with it.
|
||||
- No comments, no docs, no tests inside the crates. `docs/` is the only place for prose.
|
||||
- No premature optimization: scan the small vectors, don't build index maps.
|
||||
- No `unwrap`, no `let _ =` on fallible calls; propagate with `?` and surface errors to the UI
|
||||
(relay messages verbatim).
|
||||
- Views read entities; protocol state never lives in a view. A panel re-reads on the event.
|
||||
- UI follows the existing components (`ui::button`, `ui::input`, `ui::dock`, `ui::message`,
|
||||
`ui::markdown`, `theme` tokens). Copy is sentence case; a dialog names its object
|
||||
(`Leave "Pizza Lovers"?` with a `Leave` button).
|
||||
- `Monitor` stays unadopted; the fork watch already works without relay status.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — `9005` deletes reach local state
|
||||
|
||||
Today a `9005` stops the relay serving the event, but the envelope cache and the timeline keep
|
||||
it. Everything else about deletion is done (the write, the `OK false` path).
|
||||
|
||||
Files: `crates/nip29/src/protocol.rs`, `crates/nip29/src/group.rs`, `crates/nip29/src/lib.rs`.
|
||||
|
||||
1. `TimelineWindow::remove(&mut self, id: EventId)` — drop the reference so a `previous` tag
|
||||
never points at a deleted event.
|
||||
2. `Group::forget(&mut self, id: EventId, cx: &mut Context<Self>)` (crate-private) —
|
||||
`messages.retain`, `index.remove`, `window.remove`; when something was removed, emit
|
||||
`GroupEvent::Updated` and `cx.notify()`. `hydrate` stays additive, so a later reload cannot
|
||||
resurrect the row once its envelope is gone.
|
||||
3. The registry's ingestion of our accepted delete: in `ensure`'s `GroupEvent::Sent` arm, when
|
||||
`event.kind == Kind::Custom(9005)`, take the target from `event.tags.event_ids().next()`,
|
||||
delete its envelope (`Kind::ApplicationSpecificData` + `Tag::identifier(target)` +
|
||||
`r = key.cache_tag()`) on the background executor, wait for that to finish, then
|
||||
`group.forget(target)` and the usual `reload(key, Some(event))`. The deletion must complete
|
||||
before the reload reads the envelopes, or the reload would see the target still cached.
|
||||
|
||||
Remote deletes (another admin's `9005` arriving live) are out of scope; the standing REQ does
|
||||
not carry them, and a relay that no longer serves the event cannot be asked for it.
|
||||
|
||||
Validation: `cargo clippy -p nip29 --all-targets`, `cargo fmt -p nip29`,
|
||||
`cargo check -p coop -p coop_web`. Manually: as an admin, delete a message and confirm the row
|
||||
disappears and stays gone after a restart.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — `crates/nip29_ui`: the group panel
|
||||
|
||||
A new crate, modeled on `community_ui`, that renders one `Entity<Group>`.
|
||||
|
||||
Files: `crates/nip29_ui/Cargo.toml`, `crates/nip29_ui/src/lib.rs`,
|
||||
`crates/nip29_ui/src/message.rs`; one line in `crates/chat_ui/src/lib.rs`.
|
||||
|
||||
```toml
|
||||
# Cargo.toml
|
||||
[dependencies]
|
||||
nip29 = { path = "../nip29" }
|
||||
chat = { path = "../chat" }
|
||||
chat_ui = { path = "../chat_ui" }
|
||||
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
|
||||
```
|
||||
|
||||
Make `chat_ui`'s text helper shared by flipping `mod text;` to `pub mod text;`. It is the only
|
||||
change to an existing UI crate in this phase.
|
||||
|
||||
```rust
|
||||
pub fn init(group: Entity<Group>, window: &mut Window, cx: &mut App) -> Entity<GroupPanel>;
|
||||
|
||||
pub struct GroupPanel {
|
||||
id: SharedString, // "group-<cache_tag>"
|
||||
focus_handle: FocusHandle,
|
||||
group: WeakEntity<Group>,
|
||||
list_state: ListState,
|
||||
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
|
||||
input: Entity<TextareaState>,
|
||||
tasks: SmallVec<[Task<Result<()>>; 4]>,
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
```
|
||||
|
||||
The panel keeps **no copy of the timeline**. Rows are `group.read(cx).messages()`; the only
|
||||
local cache is parsed markdown keyed by event id. Deleting a message, a page arriving, or a
|
||||
live tail all reduce to "the entity changed, re-read it" — no merge, no index.
|
||||
|
||||
Rows: `0` welcome, `1` load-older, then the messages.
|
||||
|
||||
- Welcome — `WelcomeMessage` with the group avatar; `Welcome to {name}` and
|
||||
`This is the start of {name}.`
|
||||
- Load older — visible when `group.oldest().is_some()`, disabled while
|
||||
`group.loading_more()`, click → `GroupsRegistry::global(cx).update(|registry, cx| registry.load_more(&key, cx))`.
|
||||
- Message — `MessageRow` with avatar, author (`PersonRegistry`), `created_at.to_human_time()`,
|
||||
and `chat_ui::text::rendered_text(&message.content, &message.mentions, &persons, true, cx)`
|
||||
cached in `rendered_texts_by_id`. Show the author on a run start (same author, gap under a
|
||||
few minutes) like `community_ui` does. Render `message.media` as chat does: one image
|
||||
full-width, several in a wrapped row.
|
||||
|
||||
Events: subscribe to the group and drive everything from it.
|
||||
|
||||
| `GroupEvent` | Panel |
|
||||
| --- | --- |
|
||||
| `Incoming` / `Updated` | sync `list_state` (`reset(item_count)` when the count changed) and `cx.notify()` |
|
||||
| `Eose` | `cx.notify()` — `group.loading()` is now false |
|
||||
| `Refused(reason)` | show the notice |
|
||||
| `Sent` | ignore; the registry reloads through the same cache path |
|
||||
|
||||
Composer, gated exactly on the NIP's flags:
|
||||
|
||||
```rust
|
||||
let metadata = group.metadata();
|
||||
let supports_text = metadata.is_none_or(|m| m.supports(Kind::ChatMessage));
|
||||
let writable = metadata.is_none_or(|m| !m.restricted) || matches!(group.membership(), Membership::Member);
|
||||
let can_send = supports_text && writable;
|
||||
```
|
||||
|
||||
Send mirrors `community_ui::send`: read `input.value()`, clear the textarea, spawn
|
||||
`group.send_message(&text, vec![], cx)` and turn an `Err` into an
|
||||
`autohide(false)` notification — that string is the relay's refusal, verbatim. The panel does
|
||||
not reload anything; `GroupEvent::Sent` already routes the accepted event through the registry.
|
||||
|
||||
Notice above the composer (and centered when there are no messages):
|
||||
|
||||
| State | Notice | Action |
|
||||
| --- | --- | --- |
|
||||
| `group.refused()` | Relay message | none |
|
||||
| not a member (`Unknown` / `Removed` / `Refused { reason }`) | `Request to join`, preceded by the refusal reason when there is one | `registry.join_key(key, None, cx)` |
|
||||
| any of the above while `metadata.closed` | `This group is closed; an invite link is required.` | disabled |
|
||||
| `Membership::Pending { .. }` | `Waiting for the relay to approve.` | disabled |
|
||||
| `!supports_text` | `This group doesn't support text messages.` | disabled |
|
||||
|
||||
`Panel` impl: `panel_id` is `group-{cache_tag}` so a group opens once; `title` is the avatar
|
||||
plus the name; `toolbar_buttons` starts empty (Phase 4 fills it).
|
||||
|
||||
Validation: `cargo clippy -p nip29_ui --all-targets`, `cargo fmt -p nip29_ui`,
|
||||
`cargo check -p coop -p coop_web`. The crate is not reachable from the UI until Phase 2, so
|
||||
review is by code and compile.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — the sidebar and its dialogs
|
||||
|
||||
The `Communities` tab becomes one level: a list of groups, a `New Group` dialog and a
|
||||
`Join Group` dialog. The two-level community view (channels, admins, members, the back
|
||||
button) is removed; with no channels it has nothing to show, and details move to Phase 4's
|
||||
dialog.
|
||||
|
||||
Files: `crates/workspace/src/sidebar/mod.rs`, `crates/workspace/src/sidebar/tab.rs`,
|
||||
`crates/workspace/src/lib.rs`, `crates/workspace/src/dialogs/mod.rs`,
|
||||
`crates/workspace/src/dialogs/new_group.rs` (replaces `new_community.rs`),
|
||||
`crates/workspace/src/dialogs/join_group.rs`, `crates/workspace/Cargo.toml`.
|
||||
|
||||
Add `nip29` and `nip29_ui` to the workspace crate.
|
||||
|
||||
`tab.rs`: `Communities` → `Groups`, label and list title `Groups`, list id `sidebar-groups`.
|
||||
`lib.rs`: `Command::NewCommunity` → `Command::NewGroup`; add `Command::JoinGroup`; both arms
|
||||
open the dialogs below.
|
||||
|
||||
`mod.rs`:
|
||||
|
||||
- `SidebarRow::Community { community }` → `SidebarRow::Group { group: Entity<Group> }`.
|
||||
- Delete `CommunitySection`, `CommunityRow`, `community_scroll`, `channels_open`,
|
||||
`admins_open`, `members_open`, `Sidebar::community`, `reset_community`,
|
||||
`render_community`, `render_community_rows`, `section_row`, `channel_row`, `member_row`,
|
||||
and the back button in `render_user`. `render` loses its community branch.
|
||||
- `rows_for(Groups)` reads `GroupsRegistry::global(cx).read(cx).groups()` in registry order
|
||||
(the `10009` order, then joins).
|
||||
- The group row: name from `metadata.name()`, falling back to `key.id().as_str()`; avatar from
|
||||
`metadata.picture`, seeded with `key.cache_tag()`; suffix for the membership state —
|
||||
`Requested` (`Pending`), `Refused`, `Removed`, and `Moved` when `elsewhere().is_some()`;
|
||||
nothing for a plain member. Click opens
|
||||
`PanelHandle::new(nip29_ui::init(group.clone(), window, cx))` in `DockPlacement::Center`.
|
||||
- Subscriptions: replace the `CommunityRegistry` subscription with `GroupsRegistry` —
|
||||
`GroupsEvent::Updated` → `cx.notify()`, `GroupsEvent::Error(error)` → notification.
|
||||
- `render_tabs`'s Groups section: `New Group` (`Command::NewGroup`), `Join Group`
|
||||
(`Command::JoinGroup`), `Browse` (`Command::ShowBrowse`, still the Phase 3 stub).
|
||||
- Remove `dialogs/new_community.rs` and its `pub mod` line — nothing dispatches it any more.
|
||||
|
||||
The two dialogs, both modeled on `new_community.rs`:
|
||||
|
||||
**New group.** Three inputs — name, group id, relay — with ok/validation:
|
||||
|
||||
```rust
|
||||
let id = GroupId::new(if raw_id.is_empty() { slug(&name) } else { raw_id })?;
|
||||
let relay = RelayUrl::parse(relay_input)?;
|
||||
registry.create(GroupKey::new(relay, id), GroupMetadata { name: Some(name), ..Default::default() }, cx);
|
||||
```
|
||||
|
||||
A parse failure keeps the dialog open and pushes a notification. The relay's answer arrives
|
||||
asynchronously as `GroupsEvent::Error(reason)` — the sidebar already shows it. There is no
|
||||
default relay; the account has no "groups relay" setting yet, so the field starts empty.
|
||||
|
||||
**Join.** One naddr input:
|
||||
|
||||
```rust
|
||||
let reference = GroupReference::parse(input)?; // accepts naddr1…?invite=CODE
|
||||
GroupsRegistry::global(cx).update(cx, |registry, cx| registry.join(reference, cx));
|
||||
```
|
||||
|
||||
Validation: `cargo check -p workspace`, `cargo clippy -p workspace --all-targets`,
|
||||
`cargo fmt -p workspace`, then run the app: the tab lists whatever the account's `10009`
|
||||
holds, no Concord screen is reachable from it, and a pasted naddr joins and opens.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — browse panel
|
||||
|
||||
`panels/browse.rs` stops being a stub. `init` takes the dock (`WeakEntity<DockArea>`) the way
|
||||
`search::init` does, so a joined group can be opened directly.
|
||||
|
||||
- Header: relay `Input` + a `Go` button; Enter submits. `RelayUrl::parse` → `registry.browse(relay, cx)`.
|
||||
- Body: `registry.browsed(&relay)` — a spinner while `Browse::loading`, then rows of
|
||||
`GroupCandidate`: avatar, name (or id), `id@relay`, and `Join`
|
||||
(`registry.join_key(candidate.key, None, cx)`, then open the panel). Rows for keys already
|
||||
in `registry.groups()` show `Joined` instead.
|
||||
- Subscribes to `GroupsEvent::Updated` and re-renders. Empty state: `No groups found.`;
|
||||
before a relay is entered: `Enter a relay to browse its groups.`
|
||||
|
||||
Validation: `cargo check -p workspace`; manually browse a public NIP-29 relay and join from a
|
||||
result, and create a group on a relay that accepts `9007`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — details, delete, pins, leave
|
||||
|
||||
Read-mostly group management, the surfaces the sidebar gave up in Phase 2 plus the two writes
|
||||
that need Phase 0.
|
||||
|
||||
- **Group details** — a dialog opened from the panel's header row: name, picture, banner,
|
||||
about, flags (`private`, `restricted`, `hidden`, `closed`, `livekit`, `parent`, `children`),
|
||||
admins with their role labels (`Group::admins().entries()`), the roles the relay advertises
|
||||
(`Group::roles()`), members when the relay publishes `39002` (`Group::members()`), and pins
|
||||
(`Group::pins()`). Read-only in this phase.
|
||||
- **Delete a message** — admin-only entry in the message row's context menu (current user in
|
||||
`admins()`) → `group.delete_event(id, cx)`; Phase 0 makes the row disappear.
|
||||
- **Pin / unpin** — the same menu: `group.update_pins(pins, cx)` with the current list plus or
|
||||
minus `Pin::Event(id)`; show `Group::pins()` as a strip above the list.
|
||||
- **Leave** — header `Leave…` opens a confirmation naming the group (`Leave "Pizza Lovers"?`);
|
||||
on confirm `registry.leave(&key, cx)` and the panel closes (dispatch `ClosePanel`, as the
|
||||
screening dialog does).
|
||||
- **Fork / migration banner** — when `group.elsewhere()` is `Some(moved)`, a banner:
|
||||
`This group may have moved to {relay}.` with a `Join` button calling
|
||||
`registry.join_key(moved, None, cx)`. Never auto-join: the NIP asks the client to offer.
|
||||
|
||||
Validation: same commands; manually delete a message, pin and unpin, leave and re-join.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — admin writes
|
||||
|
||||
The remaining moderation surface, all through the calls the backend already exposes.
|
||||
|
||||
- **Edit metadata** — a dialog opened from the panel header's `Manage` menu, started from
|
||||
`group.metadata().cloned().unwrap_or_default()`: name, about, picture, the flags. `children`
|
||||
is preserved as-is — `edit_metadata` re-emits the full list, and a `9002` that drops one is
|
||||
rejected by the relay.
|
||||
- **Invite** — `create_invite(code, cx)` with an input or generated code, then
|
||||
`metadata.naddr(relay, Some(code))` copied to the clipboard (`naddr` needs parsed metadata;
|
||||
a group whose relay publishes no `39000` cannot mint a link).
|
||||
- **Users and roles** — add by `npub` → `put_user(pubkey, roles, cx)`, remove →
|
||||
`remove_user(pubkey, cx)`; role choices come from `Group::roles()`. The relay is the
|
||||
authority for both the roles it supports and who may call these.
|
||||
- **React** — a `React` submenu in the same row menu sends `send_reaction(target, emoji, cx)`;
|
||||
a kind `7` row appears inline (that is the current backend behaviour). Unlike the items above
|
||||
it is not admin-only, so the menu now exists for every member.
|
||||
|
||||
Validation: same commands; manually exercise each write against a relay that grants the
|
||||
account admin, and confirm the refusal path shows the relay's message.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — delete the Concord stack
|
||||
|
||||
Nothing in the app may reference Concord before this phase starts.
|
||||
|
||||
1. Delete `crates/community`, `crates/concord`, `crates/community_ui`.
|
||||
2. `crates/workspace/Cargo.toml`: drop `community` and `community_ui`.
|
||||
`desktop/Cargo.toml` and `web/Cargo.toml`: drop `community`; `nip29` stays.
|
||||
3. `desktop/src/main.rs` and `web/src/lib.rs`: drop `community::init(cx)` and its comment.
|
||||
4. `crates/settings/src/lib.rs`: drop `recent_communities` and `record_recent_community` —
|
||||
nothing reads them (verified), and the concept no longer exists.
|
||||
5. `docs/nip29-usage.md`: replace the "Not wired up yet" section. Remaining known gaps then
|
||||
are: LiveKit's response field names (a guess until checked against a real relay), the AV
|
||||
room itself, local handling of remote `9005`s, the subgroup tree, and reaction grouping.
|
||||
6. Confirm no `concord`/`community` string remains outside `docs/` history, and run a full
|
||||
`cargo check -p coop -p coop_web` plus `cargo clippy --workspace --all-targets`.
|
||||
|
||||
---
|
||||
|
||||
## Later (not part of the migration)
|
||||
|
||||
- **LiveKit** — `Group::livekit_supported` and `Group::livekit_session` are ready; check
|
||||
`metadata.livekit`, then build the AV room UI. Verify the token response fields first.
|
||||
- **Subgroups** — `parent`/`children` are parsed; assemble the tree in the sidebar when the
|
||||
relay advertises `nip29.subgroups`.
|
||||
- **Replies and threads** — `send_message` already takes reply ids and `chat::Message` reads
|
||||
them back; the panel needs a reply affordance and a quoted row.
|
||||
- **Remote deletions** — fold a `9005` seen live into `Group::forget`, the same eviction as
|
||||
Phase 0.
|
||||
- **Recent groups** — if a local recency list is wanted, store group keys, not the removed
|
||||
`recent_communities` strings.
|
||||
|
||||
## Validation checklist (every phase)
|
||||
|
||||
```sh
|
||||
cargo clippy -p <touched-crates> --all-targets
|
||||
cargo fmt -p <touched-crates> # check the exit code, not the output
|
||||
cargo check -p coop -p coop_web
|
||||
```
|
||||
|
||||
Plus the manual smoke for the phase, run in the app. The wasm target is not build-verified in
|
||||
this repo; `coop_web` check is the proxy.
|
||||
|
||||
## Decisions taken
|
||||
|
||||
- The tab is renamed `Groups`; the vocabulary is the NIP's.
|
||||
- The sidebar keeps one level; admins and members move to the details dialog.
|
||||
- The panel reads `Group` directly and keeps no timeline copy.
|
||||
- Mentions are rendered (shared `chat_ui::text::rendered_text`); replies and attachments are
|
||||
not offered for sending in v1.
|
||||
- `Join` never happens implicitly — not from `10009` alone, and not from `elsewhere()`.
|
||||
- The old crates are deleted only at the end, so any phase can be stopped and the app still
|
||||
runs.
|
||||
- The dock renders no per-panel toolbar — `Panel::toolbar_buttons` is defined but never called —
|
||||
so the group's `Details` and `Leave` actions live in a header row inside the panel.
|
||||
- The fork banner's button joins the target group rather than opening it: the panel holds no
|
||||
dock handle and the plan's call is `join_key` alone; the joined group then appears in the
|
||||
sidebar.
|
||||
- The admin writes sit behind a `Manage` menu in the panel header, shown only while the current
|
||||
user is in `Group::admins()`; the details dialog stays read-only. The message row menu is now
|
||||
built for every member — `React` is not moderation — and only its `Pin`/`Delete` items are
|
||||
gated on being an admin.
|
||||
@@ -0,0 +1,625 @@
|
||||
# Using the groups backend
|
||||
|
||||
`crates/nip29` is the backend for [NIP-29](https://github.com/nostr-protocol/nips/blob/master/29.md)
|
||||
relay-based groups. It is a GPUI crate: it owns one registry entity and one entity
|
||||
per group, subscribes to the relays, caches everything locally and hands the UI
|
||||
`chat::Message`s. It owns no strings a user reads — the UI layer decides every
|
||||
rendering. This document is the map from a UI action to the calls it makes.
|
||||
|
||||
A group is a `(relay, id)` pair. The relay enforces membership and signs the
|
||||
group's state; there is no cross-relay identity, no encryption, and no portable
|
||||
"create". The same `id` on two relays is two groups (a fork), which the backend
|
||||
models as two entities.
|
||||
|
||||
## Modules
|
||||
|
||||
| File | Owns |
|
||||
| --- | --- |
|
||||
| `src/protocol.rs` | Pure NIP-29 value types and their `impl` blocks: `GroupId`, `GroupKey`, `GroupReference`, `GroupMetadata`, `GroupAdmins`/`GroupMembers`/`GroupRoles`/`GroupParticipants`, `Pin`/`GroupPins`, `LivekitSession`, `GroupList`/`GroupListEntry`, `Membership`, `TimelineWindow`, plus the filter and event builders on `GroupId`. No I/O. |
|
||||
| `src/group.rs` | The `Group` entity: one group's state, timeline, membership, writes, the envelope cache helpers and the two LiveKit calls. `GroupEvent`. |
|
||||
| `src/lib.rs` | The `GroupsRegistry` global, `init`, the notification pump, the account's `10009` list, the fork watch and relay browsing. `GroupsEvent`. |
|
||||
|
||||
`protocol` is re-exported at the crate root (`pub use protocol::*`), so callers
|
||||
write `nip29::GroupKey`, `nip29::GroupMetadata`. `group` is re-exported the same
|
||||
way (`Group`, `GroupEvent`).
|
||||
|
||||
The crate's constants are the protocol's: `RENDER_KINDS` (`1`, `9`, `11`, `1111`,
|
||||
`7`), `STATE_KINDS` (`39000`–`39004` and `39005`), `MEMBERSHIP_KINDS` (`9000`,
|
||||
`9001`), `PIN_LIST` (`39005`), `UPDATE_PIN_LIST` (`9010`), `TIMELINE_WINDOW`
|
||||
(50) and the `previous`-tag shape (3 refs, 8 hex chars).
|
||||
|
||||
## Turning it on
|
||||
|
||||
One line, after the other registries and gated on nothing — the registry does its
|
||||
own sign-in check:
|
||||
|
||||
```rust
|
||||
nip29::init(cx);
|
||||
```
|
||||
|
||||
It builds the global entity, subscribes to `NostrRegistry`, and on the first
|
||||
sign-in (or a signer change) resets its state, restarts the pump and loads the
|
||||
account's `10009`. No groups exist before a sign-in, and everything is torn down
|
||||
when the signer changes, so the crate never leaks one account's groups into
|
||||
another.
|
||||
|
||||
`nip29::init(cx)` is already called in `desktop/src/main.rs` and `web/src/lib.rs`.
|
||||
|
||||
```rust
|
||||
use nip29::GroupsRegistry;
|
||||
|
||||
let groups = GroupsRegistry::global(cx); // Entity<GroupsRegistry>
|
||||
groups.read(cx).groups(); // &[Entity<Group>]
|
||||
groups.read(cx).group(&key, cx); // Option<Entity<Group>>
|
||||
```
|
||||
|
||||
`GroupsEvent` is emitted on the registry; read it back on the event. The sidebar
|
||||
subscribes to `GroupsEvent` and renders `groups()` — pending and refused groups
|
||||
are in that list alongside members, and their `Group::membership()` says which.
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum GroupsEvent {
|
||||
Updated,
|
||||
Error(String),
|
||||
Browsed(Box<GroupCandidate>),
|
||||
BrowseSettled(RelayUrl),
|
||||
}
|
||||
```
|
||||
|
||||
`Updated` fires on any change to `groups()`; `Error` is only emitted when
|
||||
publishing a `9007` (create) is refused, and carries the relay's message.
|
||||
`Browsed` and `BrowseSettled` carry a browse: one group the relay announced, and
|
||||
the end of the answer (see [Browsing a relay](#browsing-a-relay)). They are
|
||||
fan-out, not stored — whatever opened the browse owns the candidates.
|
||||
|
||||
## Value types
|
||||
|
||||
```rust
|
||||
use nip29::{GroupId, GroupKey, GroupReference, GroupMetadata};
|
||||
|
||||
let id = GroupId::new("pizza-lovers")?; // trimmed, non-empty
|
||||
let key = GroupKey::new(relay_url.clone(), id); // the (relay, id) pair
|
||||
```
|
||||
|
||||
A `GroupKey` is the identity of a group *as this backend sees it*: everything
|
||||
that addresses a group — `group`, `join_key`, `create`, `load_more`, `leave`,
|
||||
`browse` results — takes one. `Group::key()` returns the one an entity was built
|
||||
with, and `GroupReference::key()` returns the one a link resolves to.
|
||||
|
||||
A reference is a link, and only a link:
|
||||
|
||||
```rust
|
||||
let reference = GroupReference::parse("naddr1…?invite=CODE")?; // or .parse::<GroupReference>()
|
||||
```
|
||||
|
||||
`GroupReference::parse` (and `FromStr`) accepts only a kind-`39000` `naddr` with a
|
||||
relay hint. The `?invite=` suffix the NIP defines is split off before bech32
|
||||
decoding and lives on `reference.invite()`; a missing relay hint is an error,
|
||||
because there is nowhere to send the join request. `id@relay` is deliberately not
|
||||
accepted.
|
||||
|
||||
`GroupMetadata` is both the read model and the `9002`/`9007` payload, and it is
|
||||
`Default`:
|
||||
|
||||
```rust
|
||||
let metadata = GroupMetadata {
|
||||
name: Some("Pizza Lovers".into()),
|
||||
private: true,
|
||||
..Default::default()
|
||||
};
|
||||
```
|
||||
|
||||
`parse(event)` fills it from a relay-signed `39000` (and keeps the event's id and
|
||||
author privately, which is what `naddr` is built from). `name()`, `supports(kind)`
|
||||
and the public flag fields (`private`, `restricted`, `hidden`, `closed`,
|
||||
`livekit`, `parent`, `children`, `supported_kinds`) are what a group header reads.
|
||||
`supports` distinguishes "tag absent" (all kinds) from "tag present with no
|
||||
values" (no kinds) — that is the difference between a normal group and an AV-only
|
||||
one.
|
||||
|
||||
## The account's group list
|
||||
|
||||
`10009` ("simple groups") is the member's own list of memberships, synced across
|
||||
devices. The registry keeps it in `list: GroupList` and owns its whole lifecycle:
|
||||
it is loaded at sign-in, `upsert`ed on join/create, `remove`d on leave, and
|
||||
re-published to `to_nip65()` after every change. A caller never touches it.
|
||||
|
||||
```rust
|
||||
// `10009` tag shape: ["group", id, relay, name?] plus ["r", relay]
|
||||
```
|
||||
|
||||
Loading is a subscription plus a database read, the same read model as everything
|
||||
else: subscribe to our own `10009` under `nip29-list`, then read the newest from
|
||||
`client.database()` and merge it. A newer list arriving from another device is
|
||||
merged union-only — the backend never diffs, so a removal another client made is
|
||||
not detected, and the local state is never walked backwards. `GroupList::parse`
|
||||
requires `["group", id, relay]` and takes the third value as the display name.
|
||||
|
||||
## Joining
|
||||
|
||||
```rust
|
||||
GroupsRegistry::global(cx).update(cx, |registry, cx| {
|
||||
registry.join(reference, cx); // or join_key(key, invite, cx)
|
||||
});
|
||||
```
|
||||
|
||||
`join` is `join_key(reference.key(), reference.invite().to_owned())`, and
|
||||
`join_key` is what every other path calls too, because a browse result has a key
|
||||
and no `naddr`. The sequence is:
|
||||
|
||||
1. `ensure` the group: create the entity, register it in `groups()`, subscribe to
|
||||
its relay (the standing `grp-<hash>` REQ, below) and reload it from the local
|
||||
cache.
|
||||
2. Add the entry to `10009` optimistically and publish the list, so the group is
|
||||
listed the moment the user asks for it.
|
||||
3. Publish `key.id().join_request(invite, "")` — the `h` tag plus, when the link
|
||||
had one, a `code` tag. `Group::mark_pending(now)` runs immediately, so
|
||||
`Group::membership()` is `Pending { since }` while the relay decides.
|
||||
|
||||
The relay either emits `9000` (the standing subscription folds it into
|
||||
`Membership::Member`), or answers the `9021` with `OK false` — the registry
|
||||
catches that and sets `Membership::Refused { reason }` from the relay's message.
|
||||
The NIP promises that message explains whether the rejection is final, pending
|
||||
review or payment-required, so show it verbatim and offer a retry. A join to a
|
||||
`closed` group is refused the same way, unless the invite code is honoured.
|
||||
|
||||
Join needs no `39000` first: nothing is fetched, the `9021` is the whole request.
|
||||
A hidden group needs no special handling either — the state simply never arrives.
|
||||
|
||||
## Reading a group
|
||||
|
||||
`Group` is an entity with no protocol state in the view. A panel reads it and
|
||||
subscribes to `GroupEvent`:
|
||||
|
||||
```rust
|
||||
use nip29::{Group, GroupEvent};
|
||||
|
||||
let Some(group) = registry.read(cx).group(&key, cx) else {
|
||||
return;
|
||||
};
|
||||
let rows = group.read(cx).rows(); // &[nip29::Row]
|
||||
```
|
||||
|
||||
```rust
|
||||
cx.subscribe(&group, |this, group, event, cx| match event {
|
||||
GroupEvent::Incoming(..) | GroupEvent::Updated => cx.notify(),
|
||||
GroupEvent::Refused(reason) => this.show_error(reason, cx),
|
||||
GroupEvent::Eose => this.stop_spinner(cx),
|
||||
GroupEvent::Sent(_) => {}
|
||||
})
|
||||
```
|
||||
|
||||
`GroupEvent` is the full surface:
|
||||
|
||||
| Variant | Means |
|
||||
| --- | --- |
|
||||
| `Incoming(Event)` | a fresh renderable row was folded in (use it for system notifications when the window is inactive) |
|
||||
| `Sent(Event)` | our own event was accepted by the relay; the registry re-reads the group, the panel normally ignores it |
|
||||
| `Updated` | metadata, state, membership, pins or a finished page changed |
|
||||
| `Eose` | the standing subscription settled |
|
||||
| `Refused(String)` | the relay `CLOSED` the standing subscription, with its message |
|
||||
|
||||
The reads are all accessors: `key`, `metadata`, `members`, `admins`, `roles`,
|
||||
`participants`, `pins`, `membership`, `elsewhere`, `rows`, `loading`,
|
||||
`loading_more`, `oldest`, `refused`. `display_name` reads the group's name and
|
||||
falls back to its id; `display_image` is the picture, for a row or avatar.
|
||||
|
||||
`rows` is the whole timeline in `created_at` order, one entry per renderable
|
||||
event. `Row::Message(Box<chat::Message>)` is a chat row; `Row::Activity(Activity)`
|
||||
is a moderation row — who joined (`9021`), who left (`9022`), who was removed
|
||||
(`9001`) and who was given roles (`9000` with roles; a `9000` without roles is the
|
||||
relay echoing a join we already show and is not a row). `Activity::subject()` is
|
||||
the affected person (the `p` tag target, or the author when there is none) and
|
||||
`Activity::roles` the assigned roles, so the view only chooses wording and an
|
||||
icon. `Group::message(id)` looks a chat row up for a pin label.
|
||||
|
||||
### The read model
|
||||
|
||||
There is **no `fetch_events` anywhere** in the crate. A group is read by
|
||||
subscribing, then querying the local database — the same model as `crates/chat`
|
||||
and `crates/community`:
|
||||
|
||||
```
|
||||
subscribe grp-<hash> the global pump observes every REQ on the client
|
||||
│ (timeline + membership + one state filter per kind, one relay)
|
||||
▼
|
||||
save_envelope 30078, d = event id, r = "<relay>|<id>", k = kind
|
||||
│ content = the raw event JSON, signed by crate-local keys
|
||||
▼
|
||||
load_envelopes query by the r tag, parse, sort by created_at
|
||||
│
|
||||
▼
|
||||
Group::hydrate the only place entity state changes
|
||||
```
|
||||
|
||||
The standing REQ carries the timeline (`GroupId::timeline_filter(100)`), our own
|
||||
membership (`membership_filter(me)`) and `state_filters()` — **one filter per
|
||||
state kind**, because a relay that serves group metadata is allowed to refuse a
|
||||
filter that mixes metadata kinds with other kinds (`groups.hzrd149.com` answers
|
||||
`blocked: it's not allowed to mix metadata kinds with others` and drops the whole
|
||||
REQ, which is what hid a group's name and picture until the filters were split).
|
||||
Its id is `grp-` plus 8 hex of a hash of the
|
||||
key — NIP-01 caps ids at 64 characters, and the pump needs a prefix to recognise
|
||||
its own subscriptions, so the real key is held in the registry's `routes`, not
|
||||
encoded in the id.
|
||||
|
||||
The envelope cache is what makes the timeline fork-safe and restart-safe without
|
||||
teaching the nostr database about relays (a stored event carries no origin): the
|
||||
`r` tag *is* the relay scoping. It is also why every event is re-read instead of
|
||||
consumed off the wire — the pipeline dedups, sorts and survives a restart, and a
|
||||
page, a live tail and a local send all take the same path.
|
||||
|
||||
`hydrate` partitions an envelope run:
|
||||
|
||||
- `STATE_KINDS` → `metadata`, `admins`, `members`, `roles`, `participants`,
|
||||
`pins` (relay-signed, read-only, addressable by `d`).
|
||||
- `MEMBERSHIP_KINDS` → our membership only (see below).
|
||||
- `RENDER_KINDS` → a `Row::Message`, inserted in `created_at` order.
|
||||
- `ACTIVITY_KINDS` → a `Row::Activity`, inserted in `created_at` order. A `9001`
|
||||
that answers a `9022` from the same subject within five minutes is dropped, so a
|
||||
plain leave reads once.
|
||||
- everything else is kept only for the `previous` window.
|
||||
|
||||
Every `h`-tagged event feeds the window regardless of kind. The window is the
|
||||
NIP's timeline reference: the last 50 events seen on that relay, excluding our
|
||||
own, first 8 hex characters of their ids, up to 3 per send. The write path
|
||||
attaches it automatically via `Group::previous_tag(me)`, so callers never build
|
||||
`previous` themselves.
|
||||
|
||||
`Group::loading()` is true until EOSE; `Group::refused()` carries the relay's
|
||||
`CLOSED` message when it never settles. A relay may answer a restricted or
|
||||
private group with `restricted:` / `auth-required:` — the state stays, the group
|
||||
just reads empty, and that is not an error toast.
|
||||
|
||||
### Older messages
|
||||
|
||||
```rust
|
||||
GroupsRegistry::global(cx).update(cx, |registry, cx| {
|
||||
registry.load_more(&key, cx);
|
||||
});
|
||||
```
|
||||
|
||||
It is a no-op while a page is in flight (`Group::loading_more()`) or before
|
||||
anything is cached. Otherwise it sends a `pg-<hash>` REQ (`timeline_page(50,
|
||||
until)`) alongside the standing one — paging must not replace the live tail — and
|
||||
the pump caches each page event and reloads the group exactly like a live event.
|
||||
The cursor is `Group::oldest()`, the oldest `h`-tagged event ever hydrated; `until`
|
||||
is inclusive, so a page repeats its first event, which the dedup absorbs for free.
|
||||
EOSE, a page `CLOSED` or the 10s auto-close all clear the spinner.
|
||||
|
||||
One relay behaviour is worth a spinner that can be cancelled: a relay that accepts
|
||||
the REQ but never EOSEs leaves `loading_more` true, because the auto-close closes
|
||||
the subscription locally with no notification at all.
|
||||
|
||||
## Sending
|
||||
|
||||
Every write goes through one path — `Group::dispatch` builds the event with
|
||||
`key.id()` and the current `previous` tag, `Group::publish` adds and connects the
|
||||
relay, signs with the app signer and sends to that one relay:
|
||||
|
||||
```rust
|
||||
let sent = group.update(cx, |group, cx| group.send_message(&text, replies, cx));
|
||||
cx.spawn(async move |_, _| {
|
||||
if let Err(error) = sent.await {
|
||||
// the relay's OK-false message, verbatim: "invalid: ...", "restricted: ...", "duplicate: ..."
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
| Call | Wire |
|
||||
| --- | --- |
|
||||
| `send_message(content, replies: Vec<EventId>, cx)` | kind `9` + `h` + `e` per reply |
|
||||
| `send_reaction(target: EventId, emoji: String, cx)` | kind `7` + `h` + `e` |
|
||||
| `put_user(public_key, roles, cx)` | `9000` |
|
||||
| `remove_user(public_key, cx)` | `9001` |
|
||||
| `edit_metadata(&GroupMetadata, cx)` | `9002` |
|
||||
| `delete_event(EventId, cx)` | `9005` |
|
||||
| `create_invite(code, cx)` | `9009` |
|
||||
| `update_pins(Vec<Pin>, cx)` | `9010` |
|
||||
|
||||
Each returns `Task<Result<()>>` and each resolves only once the relay has
|
||||
answered: `OK true` emits `GroupEvent::Sent` and the registry reloads the event
|
||||
through the normal cache path, so the timeline shows a send without waiting for
|
||||
the live tail; `OK false` is an `Err` carrying the relay's message. There is no
|
||||
offline queue — NIP-29 relays reject late publication, so a send either goes now
|
||||
or fails visibly.
|
||||
|
||||
Kind 9 is the default message kind. When `metadata().supports(Kind::ChatMessage)`
|
||||
is false — an AV-only group, or one whose `supported_kinds` omits it — disable the
|
||||
composer instead of sending a kind the relay does not take.
|
||||
|
||||
`replies` are plain `EventId`s; they become `e` tags. There is no thread model in
|
||||
the backend beyond that: `chat::Message::replies_to` already reads them back.
|
||||
|
||||
## Membership
|
||||
|
||||
Membership is derived from the relay's own events, never guessed:
|
||||
|
||||
```rust
|
||||
pub enum Membership {
|
||||
Unknown,
|
||||
Pending { since: Timestamp },
|
||||
Member,
|
||||
Refused { reason: Option<String> },
|
||||
Removed,
|
||||
}
|
||||
```
|
||||
|
||||
`Membership::from_events(&[...], me)` takes the newest `9000`/`9001` whose `p`
|
||||
tags name us — on an exact timestamp tie it prefers `9001` — and `Unknown` when
|
||||
there is none, which the NIP says means "not a member". The `Group` keeps only
|
||||
the membership events that mention us, so `refresh_membership` costs nothing.
|
||||
|
||||
`Pending` and `Refused` are the two local states, because the relay does not fold
|
||||
them into an event: the join path sets them from the `9021` send result. A
|
||||
`Member` is never overwritten by `mark_pending` or `mark_refused`, so a stale
|
||||
refusal cannot demote a member.
|
||||
|
||||
Count a member who is `Unknown`, `Pending` or `Refused` as "not in the room" for
|
||||
writes, but keep the row visible: pending and refused groups stay in `groups()`
|
||||
with their state on the row.
|
||||
|
||||
## Moderation
|
||||
|
||||
The `9000`–`9010` writes are on `Group` and share `dispatch`, so they are the
|
||||
same shape as sending a message. The backend does not enforce authority — the
|
||||
relay owns roles and its own policy — it only surfaces the refusal. Gate the UI
|
||||
on the caller appearing in `Group::admins()` (each entry is a pubkey plus its role
|
||||
labels), read the relay's supported roles from `Group::roles()`, and let the relay
|
||||
reject anything it does not want.
|
||||
|
||||
`edit_metadata` takes a `GroupMetadata`, so a form can start from
|
||||
`Group::metadata().cloned().unwrap_or_default()`, mutate, and send. `children`
|
||||
must carry every child tag, because a `9002` that drops one is rejected by the
|
||||
relay; the builder re-emits them from the struct.
|
||||
|
||||
`delete_event` sends `9005`, and once the relay accepts it the registry drops the
|
||||
target's cached envelope, evicts the row and re-reads the group, so the timeline stops
|
||||
showing it. Remote deletions are not folded in: the standing REQ does not carry `9005`.
|
||||
|
||||
## Pins
|
||||
|
||||
```rust
|
||||
use nip29::Pin;
|
||||
|
||||
let pins: Vec<Pin> = vec![Pin::Event(event_id), Pin::Address(coordinate)];
|
||||
group.update(cx, |group, cx| group.update_pins(pins, cx));
|
||||
```
|
||||
|
||||
`9010` carries the whole list, so pinning, unpinning, reordering and clearing are
|
||||
all one call. The relay mirrors it into `39005`, which is in `STATE_KINDS` — so
|
||||
`Group::pins()` reflects the accepted list on the next reload.
|
||||
|
||||
## Subgroups
|
||||
|
||||
`GroupMetadata` carries `parent: Option<GroupId>` and `children: Vec<GroupId>`,
|
||||
and the metadata builder re-emits both, so a create or edit can declare the link.
|
||||
There is no tree helper: the tree is scoped to one relay, so assemble it from
|
||||
`Group::metadata()` across `groups()` where all the children have the same relay.
|
||||
Membership and admin roles do **not** cascade across the link — each group's
|
||||
`9000`/`9001` and `39001` are authoritative for its own scope — so never infer
|
||||
access from a parent.
|
||||
|
||||
## LiveKit
|
||||
|
||||
Two calls, both off the foreground thread and both `&App`:
|
||||
|
||||
```rust
|
||||
if group.read(cx).metadata().is_some_and(|m| m.livekit) {
|
||||
let available = group.read(cx).livekit_supported(cx).await?; // GET /.well-known/nip29/livekit
|
||||
let session = group.read(cx).livekit_session(cx).await?; // LivekitSession { token, server_url }
|
||||
}
|
||||
```
|
||||
|
||||
`livekit_supported` is an unauthenticated GET that answers `true` only for `204`.
|
||||
`livekit_session` signs a NIP-98 `Authorization` header (`27235`, with the `u` and
|
||||
`method` tags the SDK's verifier demands) and returns the JWT and server URL; the
|
||||
token is a per-join artifact, returned not stored. Both requests carry an explicit
|
||||
`RedirectPolicy::NoFollow` and a 10s timeout, because the header is signed for
|
||||
exactly one URL and GPUI applies no timeout unless asked. The backend stops at the
|
||||
JWT — no LiveKit client library is involved — so the AV room itself is UI work.
|
||||
|
||||
Check `metadata().livekit` before offering the button, and read
|
||||
`Group::participants()` (kind `39004`, `participant` tags, relay-published) for
|
||||
who is live. Absent and empty are the same value there: nobody is in the room.
|
||||
|
||||
## Forks and migration
|
||||
|
||||
A `10009` on the *admins'* relays says where each of them thinks the group is. The
|
||||
registry keeps one standing `nip29-forks` subscription over
|
||||
`{authors: every admin of every group, kinds: [10009]}` on `ReqTarget::auto` —
|
||||
which is why it still works when the group's own relay is down — folds each event
|
||||
into a per-author list, and recomputes every group's `elsewhere`.
|
||||
|
||||
```rust
|
||||
if let Some(moved) = group.read(cx).elsewhere() {
|
||||
// the group may have moved or been forked; offer:
|
||||
registry.update(cx, |registry, cx| registry.join_key(moved.clone(), None, cx));
|
||||
}
|
||||
```
|
||||
|
||||
`elsewhere()` is `Some(key)` when an admin of the group lists the same group id at
|
||||
a different relay. The data cannot distinguish a move from a fork, hence the
|
||||
neutral name; nothing is auto-joined, because the NIP asks the client to *offer*.
|
||||
Evaluation is incremental on each event rather than batched on EOSE, since
|
||||
`ReqTarget::auto` fans out and an early EOSE would evaluate against one relay's
|
||||
partial view. A 15-minute background tick re-subscribes regardless, which is the
|
||||
recovery path for a subscription that died or a client that was offline.
|
||||
|
||||
`Monitor` is not adopted: it is only reachable through `ClientBuilder::monitor`,
|
||||
so nothing can read per-relay status without changing `crates/state`. The fork
|
||||
check does not need it — the admin pubkeys come from the cached `39001`, so the
|
||||
check runs with the relay gone, just at the next tick.
|
||||
|
||||
## Browsing a relay
|
||||
|
||||
```rust
|
||||
registry.update(cx, |registry, cx| registry.browse(relay_url.clone(), cx));
|
||||
|
||||
// then, in a GroupsEvent handler:
|
||||
match event {
|
||||
GroupsEvent::Browsed(candidate) => push(candidate),
|
||||
GroupsEvent::BrowseSettled(relay) => settled(relay),
|
||||
_ => {}
|
||||
}
|
||||
```
|
||||
|
||||
A `GroupCandidate` is a `GroupKey` plus its parsed `GroupMetadata`; `display_name`
|
||||
falls back to the id and `display_image` is the picture, the same pair `Group`
|
||||
offers. `browse` connects the relay, sends one auto-closing `39000` REQ (limit
|
||||
500, EOSE or 10s) and returns immediately; the pump parses each relay-signed
|
||||
metadata event and re-emits it as `Browsed`.
|
||||
|
||||
**The registry stores no browse result.** It keeps only which relay each `brw-`
|
||||
REQ belongs to — the same routing bookkeeping as `routes`, and the reason
|
||||
`Browsed` can carry a boxed candidate instead of the registry holding a `Vec`:
|
||||
the only copy is the caller's. A browse is transient by nature, so the surface
|
||||
that asked for it owns it (the browse panel keeps `relay`, `candidates` and
|
||||
`loading` in its own state, and drops them with the panel).
|
||||
|
||||
`BrowseSettled` is driven by the subscribe task itself, not by `EOSE`/`CLOSED`
|
||||
notifications: it is sent once `subscribe(..).await` returns, which happens on
|
||||
EOSE, on a refusal, or when the auto-close timeout fires. So the answer always
|
||||
ends, even against a relay that never EOSEs. A candidate can still arrive after
|
||||
the settle — the pump and the subscribe task are different producers — which is
|
||||
why the relay stays mapped. Join a result with `join_key(candidate.key, None, cx)`.
|
||||
|
||||
## Creating a group
|
||||
|
||||
```rust
|
||||
let metadata = GroupMetadata { name: Some(name), ..Default::default() };
|
||||
registry.update(cx, |registry, cx| registry.create(key, metadata, cx));
|
||||
```
|
||||
|
||||
Creation mirrors join: `ensure` the entity, add it to `10009` and publish optimistically,
|
||||
then send the `9007`. There is no NIP-11 check to lean on, so "where the relay
|
||||
supports it" is enforced by the relay refusing the event — on refusal the registry
|
||||
forgets the group and emits `GroupsEvent::Error(reason)`. On success the relay
|
||||
republishes `39000`, which the standing subscription picks up like any other state.
|
||||
|
||||
## Leaving
|
||||
|
||||
```rust
|
||||
registry.update(cx, |registry, cx| registry.leave(&key, cx));
|
||||
```
|
||||
|
||||
`leave` sends `9022`; once the relay accepts, the registry forgets the group:
|
||||
unsubscribe both REQs, drop the entity and its observer, remove the `10009` entry,
|
||||
delete every cached envelope for that group's `r` tag, and publish the shortened
|
||||
list. A failed leave leaves everything untouched, so the group stays. There is no
|
||||
separate local removal — a group someone else removed us from stays listed until
|
||||
we leave it ourselves.
|
||||
|
||||
## GPUI integration
|
||||
|
||||
`init` builds the global; the registry and its groups are the only entities, and
|
||||
`protocol.rs` has no GPUI dependency at all.
|
||||
|
||||
```rust
|
||||
pub fn init(cx: &mut App) {
|
||||
GroupsRegistry::set_global(cx.new(GroupsRegistry::new), cx);
|
||||
}
|
||||
|
||||
impl GroupsRegistry {
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalGroupsRegistry>().0.clone()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `GroupsRegistry` holds `groups: Vec<Entity<Group>>`, the `10009` `list`,
|
||||
`routes` (subscription id → `GroupKey`), the relay behind each live `brw-` REQ,
|
||||
one `Subscription` per group, the fork state, and its tasks. It holds no browse
|
||||
result: a browse is fan-out, and the caller keeps the candidates.
|
||||
- `Group` owns everything the panel draws: metadata, admins/members/roles,
|
||||
participants, pins, membership, the `Vec<Row>` timeline, the `previous`
|
||||
window, `loading`/`loading_more`/`refused`.
|
||||
- Views render `Entity<Group>` and re-read on `GroupEvent`; no protocol state
|
||||
lives in a view.
|
||||
- The registry re-broadcasts every `GroupEvent::Updated` as `GroupsEvent::Updated`
|
||||
and notifies itself, so a view that watches only the registry (the sidebar,
|
||||
which renders `groups()` as rows) stays in sync when a group's metadata,
|
||||
membership, pins or fork state change. `GroupEvent::Updated` fires for state and
|
||||
membership only, never for an arriving row — that is `GroupEvent::Incoming`,
|
||||
and it is why a rebuild of the row list does not happen per row. A view that
|
||||
renders the timeline watches the `Group` itself.
|
||||
|
||||
### Foreground and background
|
||||
|
||||
One background task drains `client.notifications()` and never touches an entity;
|
||||
it sends signals through an unbounded `flume` channel that one foreground
|
||||
`cx.spawn` consumes, which is the only place entities change:
|
||||
|
||||
```
|
||||
notifications() ──sig──► flume ──recv──► this.update(cx, …) ──► handle_event → reload → hydrate
|
||||
```
|
||||
|
||||
- `handle_notifications` runs on `cx.background_spawn`; it matches
|
||||
`ClientNotification::Message` and routes the message by subscription id.
|
||||
- The consumer runs on `cx.spawn`; every arm is one `this.update(cx, ..)`.
|
||||
- The listener and the consumer are replaced (`= None` first) on a signer change,
|
||||
because a dropped `Task` cancels; that is what makes the reset clean.
|
||||
- Both `save_envelope` and `load_envelopes` are `cx.background_spawn` work that
|
||||
clones the `Client` in, so no database call ever runs on the foreground thread.
|
||||
- NIP-46 signing is async, so all signing happens inside the background task.
|
||||
|
||||
### Subscriptions and routing
|
||||
|
||||
One `Client` and one notification stream are shared with `chat`, so every
|
||||
subscription the crate owns is identified by a prefix and nothing else:
|
||||
|
||||
| Id | Meaning |
|
||||
| --- | --- |
|
||||
| `nip29-list` | the account's `10009` |
|
||||
| `nip29-forks` | the admins' `10009` |
|
||||
| `grp-<hash>` | a group's standing REQ (timeline + membership + state filters) |
|
||||
| `pg-<hash>` | a "load more" page |
|
||||
| `brw-<hash>` | a relay browse |
|
||||
|
||||
Inbound events are routed by `subscription_id` from `RelayMessage::Event`, never
|
||||
by kind. That matters because `crates/chat` treats every kind-1059 event as a
|
||||
NIP-59 gift wrap — anything a new subscription adds must be recognisable by id, and
|
||||
the pump drops ids it did not mint. Duplicate event ids are filtered in the pump
|
||||
before they reach the consumer, so a replay cannot double-hydrate.
|
||||
|
||||
## Not wired up yet
|
||||
|
||||
- **Administration is built on the client side.** The sidebar's `Groups` tab lists
|
||||
`GroupsRegistry::groups()`, opens a `GroupPanel`, and offers the new-group and
|
||||
join dialogs; `crates/workspace/src/panels/browse.rs` browses a relay and joins
|
||||
from a result. The panel has a read-only details dialog, a message menu with
|
||||
reactions and admin pin/delete, a pins strip, a fork banner, and leave. Opening a
|
||||
group also fills the sidebar with the group itself — banner (the `39000` `banner`
|
||||
tag, falling back to its `picture`), avatar and name, a row of info / edit / invite /
|
||||
leave buttons, and the collapsible `Admins`/`Members` lists. Clicking a member opens
|
||||
a dropdown menu (view profile, copy npub, and — for admins — remove from group).
|
||||
There is no members-and-roles dialog: adding a member is done through the invite
|
||||
flow, and roles are read-only. The panel has no header: the group's `about` lives
|
||||
in the details dialog, and the composer's leading button shows or hides the
|
||||
sidebar's group information. What a given account may actually do is the relay's
|
||||
call; the client shows the refusal verbatim.
|
||||
- **The composer uploads to the configured blossom server.** `GroupPanel`'s
|
||||
paperclip uploads one file at a time, unencrypted, and appends its URL to the
|
||||
message on send — the same plain-attachment path the chat composer uses when a
|
||||
message is already being typed. The server comes from
|
||||
`AppSettings::get_file_server`; the upload itself is `state::upload`.
|
||||
- **Remote deletions are not folded in.** A `9005` from another admin never reaches the
|
||||
standing REQ, so only our own accepted deletes evict the row.
|
||||
- **The LiveKit response shape is a guess.** The NIP names no fields, so
|
||||
`LivekitSession::parse` reads `token` (or `jwt`) and `url` (or `server_url`) and
|
||||
fails loudly on anything else. Re-check it against the relay actually targeted
|
||||
before building the AV UI, or the join button fails silently.
|
||||
- **A relay that never EOSEs bounds how long "load more" spins.**
|
||||
`Group::loading_more` relies on EOSE or CLOSED, and the auto-close timeout
|
||||
closes the subscription with no notification at all; bind "load more" to
|
||||
`Group::loading_more()` and give the user a way out. A browse is fine by
|
||||
contrast: `BrowseSettled` comes from the subscribe task returning, so it
|
||||
always lands.
|
||||
- **Subgroups are parsed, not assembled.** `parent`/`children` are on
|
||||
`GroupMetadata` and the writes carry them; there is no tree builder, because
|
||||
the tree is scoped to a relay and that is a view concern.
|
||||
- **`GroupMetadata::naddr` needs a parsed metadata.** It builds from the `id` and
|
||||
author the `39000` left on the struct, so a hand-built `Default` value errors
|
||||
rather than minting a link that points nowhere. A group whose relay publishes
|
||||
no `39000` cannot be referenced or joined at all.
|
||||
+1
-1
@@ -16,7 +16,7 @@ common = { path = "../crates/common" }
|
||||
state = { path = "../crates/state" }
|
||||
device = { path = "../crates/device" }
|
||||
chat = { path = "../crates/chat" }
|
||||
community = { path = "../crates/community" }
|
||||
nip29 = { path = "../crates/nip29" }
|
||||
settings = { path = "../crates/settings" }
|
||||
person = { path = "../crates/person" }
|
||||
|
||||
|
||||
+2
-2
@@ -70,8 +70,8 @@ pub fn run() -> Result<(), JsValue> {
|
||||
// Initialize app registry
|
||||
chat::init(cx);
|
||||
|
||||
// Initialize community registry
|
||||
community::init(cx);
|
||||
// Initialize groups registry
|
||||
nip29::init(cx);
|
||||
|
||||
cx.open_window(WindowOptions::default(), |window, cx| {
|
||||
cx.new(|cx| Root::new(workspace::init(window, cx).into(), window, cx))
|
||||
|
||||
Reference in New Issue
Block a user