Compare commits
22
Commits
master
...
857795a92e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
857795a92e | ||
|
|
61aa7c4a70 | ||
|
|
41e48de4fd | ||
|
|
eda8eb543d | ||
|
|
8c7c672675 | ||
|
|
ce1cff83ff | ||
|
|
5b87b2fe27 | ||
|
|
6e24a6033e | ||
|
|
6b068091c1 | ||
|
|
41045d4f88 | ||
|
|
dbdfa8ad67 | ||
|
|
cb10c2d26e | ||
|
|
99124eaf1b | ||
|
|
4678e5879f | ||
|
|
cfec6dfc85 | ||
|
|
cd7b459751 | ||
|
|
d621b125a9 | ||
|
|
de862637f3 | ||
|
|
ee9a852a8f | ||
|
|
c6f7956dff | ||
|
|
c477246bb8 | ||
|
|
338783f394 |
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"
|
||||
|
||||
@@ -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,744 @@
|
||||
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 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,914 @@
|
||||
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())
|
||||
.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()
|
||||
}
|
||||
}
|
||||
|
||||
#[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,59 @@
|
||||
use gpui::{App, AppContext, ParentElement, Styled, WeakEntity, Window, div, px};
|
||||
use nostr_sdk::prelude::SecretKey;
|
||||
use theme::ActiveTheme;
|
||||
use ui::dialog::DialogButtonProps;
|
||||
use ui::input::InputState;
|
||||
use ui::{WindowExtension, v_flex};
|
||||
|
||||
use crate::{GroupPanel, field};
|
||||
|
||||
const CODE_LEN: usize = 12;
|
||||
|
||||
pub(crate) fn open(panel: WeakEntity<GroupPanel>, 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 panel = panel.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;
|
||||
}
|
||||
|
||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
||||
panel.create_invite(code, window, cx);
|
||||
}) {
|
||||
log::warn!("nip29: creating an invite failed: {error}");
|
||||
}
|
||||
|
||||
true
|
||||
})
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
||||
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 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);
|
||||
|
||||
MessageRow::new(ix)
|
||||
.show_author(true)
|
||||
.hide_avatar(hide_avatar)
|
||||
.avatar(
|
||||
Avatar::new(subject.avatar())
|
||||
.seed(subject.avatar_seed())
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
.author(SharedString::from(format!(
|
||||
"{} {}",
|
||||
subject.name(),
|
||||
action(activity)
|
||||
)))
|
||||
.timestamp(activity.created_at.to_human_time())
|
||||
.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,176 @@
|
||||
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::{GroupPanel, 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>,
|
||||
panel: WeakEntity<GroupPanel>,
|
||||
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 panel = panel.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;
|
||||
|
||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
||||
panel.edit_metadata(next, window, cx);
|
||||
}) {
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, Entity, IntoElement, ParentElement, SharedString, Styled,
|
||||
WeakEntity, Window, div, px,
|
||||
};
|
||||
use nip29::{Group, Role};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::input::InputState;
|
||||
use ui::notification::Notification;
|
||||
use ui::switch::Switch;
|
||||
use ui::{Sizable, StyledExt, WindowExtension, divider, h_flex, v_flex};
|
||||
|
||||
use crate::{GroupPanel, field};
|
||||
|
||||
pub(crate) fn open(
|
||||
group: WeakEntity<Group>,
|
||||
panel: WeakEntity<GroupPanel>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let input = cx.new(|cx| InputState::new(window, cx).placeholder("npub1…"));
|
||||
let selected = cx.new(|_cx| Vec::<String>::new());
|
||||
|
||||
window.open_dialog(cx, move |this, _window, cx| {
|
||||
let group = group.clone();
|
||||
let panel = panel.clone();
|
||||
let input = input.clone();
|
||||
let selected = selected.clone();
|
||||
|
||||
let admins = group
|
||||
.read_with(cx, |group, _cx| group.admins().entries().to_vec())
|
||||
.unwrap_or_default();
|
||||
let roles = group
|
||||
.read_with(cx, |group, _cx| group.roles().roles().to_vec())
|
||||
.unwrap_or_default();
|
||||
let chosen = selected.read(cx).clone();
|
||||
|
||||
this.width(px(460.))
|
||||
.max_h(px(620.))
|
||||
.show_close(true)
|
||||
.title("Members and roles")
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_4()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(div().text_sm().font_medium().child("Admins"))
|
||||
.when(admins.is_empty(), |this| {
|
||||
this.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child("The relay lists no admins."),
|
||||
)
|
||||
})
|
||||
.children(
|
||||
admins
|
||||
.iter()
|
||||
.map(|(key, roles)| admin(key, roles, &panel, cx)),
|
||||
),
|
||||
)
|
||||
.child(divider(cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(div().text_sm().font_medium().child("Add a member"))
|
||||
.child(field("Public key", &input, cx))
|
||||
.when(!roles.is_empty(), |this| {
|
||||
this.child(
|
||||
v_flex().gap_2().children(
|
||||
roles
|
||||
.iter()
|
||||
.map(|role| role_switch(role, &selected, &chosen)),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(Button::new("add-user").label("Add").small().on_click({
|
||||
let panel = panel.clone();
|
||||
let input = input.clone();
|
||||
let selected = selected.clone();
|
||||
|
||||
move |_event, window, cx| {
|
||||
let value = input.read(cx).value().trim().to_owned();
|
||||
|
||||
let public_key = match PublicKey::parse(&value) {
|
||||
Ok(public_key) => public_key,
|
||||
Err(error) => {
|
||||
window.push_notification(
|
||||
Notification::error(error.to_string()),
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let roles = selected.read(cx).clone();
|
||||
|
||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
||||
panel.put_user(public_key, roles, window, cx);
|
||||
}) {
|
||||
log::warn!("nip29: adding a group member failed: {error}");
|
||||
}
|
||||
|
||||
input.update(cx, |input, cx| {
|
||||
input.set_value("", window, cx);
|
||||
});
|
||||
selected.update(cx, |selected, cx| {
|
||||
selected.clear();
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})),
|
||||
),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
fn admin(
|
||||
public_key: &PublicKey,
|
||||
roles: &[String],
|
||||
panel: &WeakEntity<GroupPanel>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let person = persons.read(cx).get(public_key, cx);
|
||||
let public_key = *public_key;
|
||||
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.min_w_0()
|
||||
.child(
|
||||
Avatar::new(person.avatar())
|
||||
.seed(person.avatar_seed())
|
||||
.xsmall(),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.min_w_0()
|
||||
.child(div().truncate().child(person.name()))
|
||||
.when(!roles.is_empty(), |this| {
|
||||
this.child(
|
||||
div()
|
||||
.truncate()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(SharedString::from(roles.join(", "))),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new(SharedString::from(format!("del-{}", public_key.to_hex())))
|
||||
.label("Remove")
|
||||
.ghost()
|
||||
.small()
|
||||
.on_click({
|
||||
let panel = panel.clone();
|
||||
|
||||
move |_event, _window, cx| {
|
||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
||||
panel.remove_user(public_key, window, cx);
|
||||
}) {
|
||||
log::warn!("nip29: removing a group member failed: {error}");
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn role_switch(role: &Role, selected: &Entity<Vec<String>>, chosen: &[String]) -> AnyElement {
|
||||
let name = role.name.clone();
|
||||
let description = role.description.clone();
|
||||
|
||||
Switch::new(SharedString::from(format!("role-{}", role.name)))
|
||||
.label(role.name.clone())
|
||||
.when_some(description, |this, description| {
|
||||
this.description(description)
|
||||
})
|
||||
.small()
|
||||
.checked(chosen.contains(&role.name))
|
||||
.on_click({
|
||||
let selected = selected.clone();
|
||||
|
||||
move |next, _window, cx| {
|
||||
selected.update(cx, |selected, cx| {
|
||||
if *next {
|
||||
if !selected.contains(&name) {
|
||||
selected.push(name.clone());
|
||||
}
|
||||
} else {
|
||||
selected.retain(|role| role != &name);
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,20 @@ impl Workspace {
|
||||
});
|
||||
});
|
||||
|
||||
// Listen app-wide, so a group panel can toggle the sidebar without holding it.
|
||||
let workspace = cx.entity().downgrade();
|
||||
|
||||
App::on_action(cx, move |action: &nip29_ui::ToggleGroupMeta, cx| {
|
||||
let Some(workspace) = workspace.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
workspace.update(cx, |this, cx| this.toggle_group_meta(action, cx));
|
||||
});
|
||||
|
||||
Self {
|
||||
dock,
|
||||
sidebar,
|
||||
tasks: vec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
@@ -282,7 +298,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 +308,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 +397,19 @@ impl Workspace {
|
||||
}
|
||||
}
|
||||
|
||||
/// Show or hide the sidebar's information for the group a panel belongs to.
|
||||
fn toggle_group_meta(&mut self, action: &nip29_ui::ToggleGroupMeta, 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.toggle_group(&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))
|
||||
}
|
||||
}
|
||||
|
||||
+314
-259
@@ -6,7 +6,6 @@ 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,
|
||||
@@ -14,6 +13,7 @@ use gpui::{
|
||||
Styled, StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, div, img,
|
||||
px, 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 or hide the group view depending on which group is being viewed.
|
||||
pub(crate) fn toggle_group(&mut self, key: &GroupKey, cx: &mut Context<Self>) {
|
||||
let groups = GroupsRegistry::global(cx);
|
||||
|
||||
let Some(entity) = groups.read(cx).group(key, cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let open = self
|
||||
.group
|
||||
.as_ref()
|
||||
.and_then(|group| group.upgrade())
|
||||
.is_some_and(|current| current.entity_id() == entity.entity_id());
|
||||
|
||||
if open {
|
||||
self.group_open = !self.group_open;
|
||||
} else {
|
||||
self.group = Some(entity.downgrade());
|
||||
self.group_open = true;
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// 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,68 +921,166 @@ 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 (banner, name, seed, picture, 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);
|
||||
}
|
||||
}
|
||||
|
||||
let metadata = group.metadata();
|
||||
|
||||
(
|
||||
metadata.and_then(|metadata| {
|
||||
metadata.banner.clone().or_else(|| metadata.picture.clone())
|
||||
}),
|
||||
group.display_name(),
|
||||
group.key().cache_tag(),
|
||||
group.display_image(),
|
||||
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(
|
||||
div()
|
||||
.h_40()
|
||||
.w_full()
|
||||
.overflow_hidden()
|
||||
.rounded(cx.theme().radius_lg)
|
||||
.child(img(banner).size_full().object_fit(ObjectFit::Cover)),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.px_2()
|
||||
.gap_2()
|
||||
.flex_shrink_0()
|
||||
.child(Avatar::from_source(picture).seed(seed).small())
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.text_sm()
|
||||
.font_medium()
|
||||
.truncate()
|
||||
.child(name),
|
||||
),
|
||||
)
|
||||
.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(), 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],
|
||||
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, 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],
|
||||
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);
|
||||
@@ -1047,12 +1091,26 @@ impl Sidebar {
|
||||
.font_medium()
|
||||
.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(", "))),
|
||||
)
|
||||
})
|
||||
.on_click({
|
||||
let public_key = *public_key;
|
||||
|
||||
move |_event, window, cx| profile::open(public_key, window, cx)
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -1092,10 +1150,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 +1212,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,623 @@
|
||||
# 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 header `Manage` menu
|
||||
(edit metadata, invite link, members and roles) shown to admins, 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, and the
|
||||
collapsible `Admins`/`Members` lists. Members are clickable and open a profile
|
||||
dialog. The panel header shows the group's `about` instead of its name, plus a
|
||||
button that 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