feat: add group discovery and group preview #59
Generated
+3
@@ -4517,9 +4517,11 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"chat",
|
"chat",
|
||||||
"flume 0.11.1",
|
"flume 0.11.1",
|
||||||
|
"futures",
|
||||||
"gpui-pre",
|
"gpui-pre",
|
||||||
"log",
|
"log",
|
||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
|
"serde_json",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"state",
|
"state",
|
||||||
]
|
]
|
||||||
@@ -9409,6 +9411,7 @@ dependencies = [
|
|||||||
"chat",
|
"chat",
|
||||||
"chat_ui",
|
"chat_ui",
|
||||||
"common",
|
"common",
|
||||||
|
"gpui-base",
|
||||||
"gpui-pre",
|
"gpui-pre",
|
||||||
"instant",
|
"instant",
|
||||||
"log",
|
"log",
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ publish.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
|
futures.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
state = { path = "../state" }
|
state = { path = "../state" }
|
||||||
chat = { path = "../chat" }
|
chat = { path = "../chat" }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
use futures::future::join_all;
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
|
use crate::protocol::{GroupCandidate, GroupId, GroupKey, GroupList, GroupMetadata};
|
||||||
|
use crate::utils::parse_members;
|
||||||
|
|
||||||
|
#[derive(Default, Clone, Debug)]
|
||||||
|
pub struct Discovery {
|
||||||
|
refs: HashMap<RelayUrl, HashSet<GroupId>>,
|
||||||
|
discovered: Vec<GroupCandidate>,
|
||||||
|
members: HashMap<GroupKey, (Timestamp, usize)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Discovery {
|
||||||
|
/// The discovered groups, sorted by name.
|
||||||
|
pub fn discovered(&self) -> &[GroupCandidate] {
|
||||||
|
&self.discovered
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The member count of a group, when its member list is known.
|
||||||
|
pub fn members(&self, key: &GroupKey) -> Option<usize> {
|
||||||
|
self.members.get(key).map(|(_, members)| *members)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The (relay, group) references, for asking the relays for metadata.
|
||||||
|
pub(crate) fn refs(&self) -> &HashMap<RelayUrl, HashSet<GroupId>> {
|
||||||
|
&self.refs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Absorb the references of a group list, skipping the joined groups.
|
||||||
|
pub(crate) fn absorb_list(&mut self, list: &GroupList, joined: &HashSet<(RelayUrl, GroupId)>) {
|
||||||
|
for entry in list.entries() {
|
||||||
|
if joined.contains(&(entry.relay.clone(), entry.id.clone())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.refs
|
||||||
|
.entry(entry.relay.clone())
|
||||||
|
.or_default()
|
||||||
|
.insert(entry.id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Absorb the newest group list of every author.
|
||||||
|
pub(crate) fn absorb_lists(
|
||||||
|
&mut self,
|
||||||
|
lists: Vec<Event>,
|
||||||
|
joined: &HashSet<(RelayUrl, GroupId)>,
|
||||||
|
) {
|
||||||
|
let newest: HashMap<PublicKey, Event> =
|
||||||
|
lists.into_iter().fold(HashMap::new(), |mut newest, event| {
|
||||||
|
match newest.get(&event.pubkey) {
|
||||||
|
Some(current) if current.created_at >= event.created_at => newest,
|
||||||
|
_ => {
|
||||||
|
newest.insert(event.pubkey, event);
|
||||||
|
newest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for event in newest.into_values() {
|
||||||
|
let Ok(list) = GroupList::parse(&event) else {
|
||||||
|
log::warn!("ignoring a malformed group list from {}", event.pubkey);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
self.absorb_list(&list, joined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a discovered group, keeping the newest metadata.
|
||||||
|
pub(crate) fn upsert(&mut self, candidate: GroupCandidate) {
|
||||||
|
match self.discovered.iter_mut().find(|d| d.key == candidate.key) {
|
||||||
|
Some(existing) if existing.created_at <= candidate.created_at => {
|
||||||
|
*existing = candidate;
|
||||||
|
}
|
||||||
|
Some(_) => return,
|
||||||
|
None => self.discovered.push(candidate),
|
||||||
|
}
|
||||||
|
|
||||||
|
self.discovered.sort_by(|a, b| {
|
||||||
|
a.display_name()
|
||||||
|
.to_lowercase()
|
||||||
|
.cmp(&b.display_name().to_lowercase())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a member count, keeping the newest list.
|
||||||
|
pub fn set_members(&mut self, key: GroupKey, created_at: Timestamp, members: usize) {
|
||||||
|
if self
|
||||||
|
.members
|
||||||
|
.get(&key)
|
||||||
|
.is_some_and(|(current, _)| *current > created_at)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.members.insert(key, (created_at, members));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seed the discoveries from the cached group metadata.
|
||||||
|
pub(crate) fn absorb_cached_previews(&mut self, previews: Vec<Event>) {
|
||||||
|
let newest: HashMap<GroupId, (Timestamp, GroupMetadata)> =
|
||||||
|
previews
|
||||||
|
.into_iter()
|
||||||
|
.fold(HashMap::new(), |mut newest, event| {
|
||||||
|
let metadata = match GroupMetadata::parse(&event) {
|
||||||
|
Ok(metadata) => metadata,
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("ignoring malformed cached group metadata: {error}");
|
||||||
|
return newest;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(id) = metadata.id().cloned() else {
|
||||||
|
return newest;
|
||||||
|
};
|
||||||
|
|
||||||
|
match newest.get(&id) {
|
||||||
|
Some((created_at, _)) if *created_at >= event.created_at => newest,
|
||||||
|
_ => {
|
||||||
|
newest.insert(id, (event.created_at, metadata));
|
||||||
|
newest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (relay, ids) in self.refs.clone() {
|
||||||
|
for id in ids {
|
||||||
|
let Some((created_at, metadata)) = newest.get(&id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hidden groups are not discoverable (NIP-29).
|
||||||
|
if metadata.hidden {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.upsert(GroupCandidate {
|
||||||
|
key: GroupKey::new(relay.clone(), id.clone()),
|
||||||
|
created_at: *created_at,
|
||||||
|
metadata: metadata.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seed the member counts from the cached member lists.
|
||||||
|
pub(crate) fn absorb_cached_members(&mut self, members: Vec<Event>) {
|
||||||
|
let newest: HashMap<GroupId, (Timestamp, usize)> =
|
||||||
|
members
|
||||||
|
.into_iter()
|
||||||
|
.fold(HashMap::new(), |mut newest, event| {
|
||||||
|
let Some((id, created_at, count)) = parse_members(&event) else {
|
||||||
|
return newest;
|
||||||
|
};
|
||||||
|
|
||||||
|
match newest.get(&id) {
|
||||||
|
Some((current, _)) if *current >= created_at => newest,
|
||||||
|
_ => {
|
||||||
|
newest.insert(id, (created_at, count));
|
||||||
|
newest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (relay, ids) in self.refs.clone() {
|
||||||
|
for id in ids {
|
||||||
|
let Some((created_at, count)) = newest.get(&id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
self.set_members(
|
||||||
|
GroupKey::new(relay.clone(), id.clone()),
|
||||||
|
*created_at,
|
||||||
|
*count,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the discoverable groups from the local database only.
|
||||||
|
pub async fn cached(client: Client, joined: HashSet<(RelayUrl, GroupId)>) -> Self {
|
||||||
|
let mut discovery = Self::default();
|
||||||
|
|
||||||
|
// The group lists of every cached author.
|
||||||
|
let lists: Vec<Event> = client
|
||||||
|
.database()
|
||||||
|
.query(Filter::new().kind(Kind::SimpleGroups))
|
||||||
|
.await
|
||||||
|
.map(|events| events.into_iter().collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
discovery.absorb_lists(lists, &joined);
|
||||||
|
|
||||||
|
let refs = discovery.refs().clone();
|
||||||
|
|
||||||
|
if refs.is_empty() {
|
||||||
|
return discovery;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ids: HashSet<&str> = refs.values().flatten().map(GroupId::as_str).collect();
|
||||||
|
|
||||||
|
// The cached metadata of the referenced groups.
|
||||||
|
let previews: Vec<Event> = client
|
||||||
|
.database()
|
||||||
|
.query(
|
||||||
|
Filter::new()
|
||||||
|
.kind(Kind::GroupMetadata)
|
||||||
|
.identifiers(ids.clone()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|events| events.into_iter().collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
discovery.absorb_cached_previews(previews);
|
||||||
|
|
||||||
|
// The cached member lists of the referenced groups.
|
||||||
|
let members: Vec<Event> = client
|
||||||
|
.database()
|
||||||
|
.query(Filter::new().kind(Kind::GroupMembers).identifiers(ids))
|
||||||
|
.await
|
||||||
|
.map(|events| events.into_iter().collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
discovery.absorb_cached_members(members);
|
||||||
|
|
||||||
|
discovery
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconcile the discoverable groups with the relays.
|
||||||
|
pub async fn sync(
|
||||||
|
&mut self,
|
||||||
|
client: Client,
|
||||||
|
me: PublicKey,
|
||||||
|
joined: HashSet<(RelayUrl, GroupId)>,
|
||||||
|
) {
|
||||||
|
let filter = Filter::new().author(me).kind(Kind::ContactList).limit(1);
|
||||||
|
|
||||||
|
let authors: HashSet<PublicKey> = client
|
||||||
|
.database()
|
||||||
|
.query(filter)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|events| events.into_iter().next())
|
||||||
|
.map(|event| event.tags.public_keys().collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let urls: Vec<RelayUrl> = client.relays().await.into_keys().collect();
|
||||||
|
let filter = Filter::new().authors(authors).kind(Kind::SimpleGroups);
|
||||||
|
client.sync(filter.clone()).with(urls).await.ok();
|
||||||
|
|
||||||
|
let lists: Vec<Event> = client
|
||||||
|
.database()
|
||||||
|
.query(filter)
|
||||||
|
.await
|
||||||
|
.map(|events| events.into_iter().collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
self.absorb_lists(lists, &joined);
|
||||||
|
|
||||||
|
// The pool-wide sweep of the group metadata and members.
|
||||||
|
let urls: Vec<RelayUrl> = client.relays().await.into_keys().collect();
|
||||||
|
let filter = Filter::new().kinds([Kind::GroupMetadata, Kind::GroupMembers]);
|
||||||
|
client.sync(filter).with(urls).await.ok();
|
||||||
|
|
||||||
|
// The previews cover the relays the lists just referenced.
|
||||||
|
let fetches = self.refs.clone().into_iter().map(|(relay, ids)| {
|
||||||
|
let client = client.clone();
|
||||||
|
|
||||||
|
async move {
|
||||||
|
client.add_relay(relay.clone()).and_connect().await.ok();
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kinds([Kind::GroupMetadata, Kind::GroupMembers])
|
||||||
|
.identifiers(ids.iter().map(GroupId::as_str));
|
||||||
|
|
||||||
|
let urls = vec![relay];
|
||||||
|
client.sync(filter).with(urls).await.ok();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
join_all(fetches).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
+193
-120
@@ -1,6 +1,4 @@
|
|||||||
use std::collections::hash_map::DefaultHasher;
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::hash::{Hash, Hasher};
|
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -10,19 +8,18 @@ use nostr_sdk::prelude::*;
|
|||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::NostrRegistry;
|
use state::NostrRegistry;
|
||||||
|
|
||||||
|
mod discovery;
|
||||||
mod group;
|
mod group;
|
||||||
mod protocol;
|
mod protocol;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
|
pub use discovery::Discovery;
|
||||||
pub use group::*;
|
pub use group::*;
|
||||||
pub use protocol::*;
|
pub use protocol::*;
|
||||||
|
pub(crate) use utils::*;
|
||||||
|
|
||||||
pub(crate) static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
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_LIMIT: usize = 500;
|
||||||
const BROWSE_TIMEOUT: Duration = Duration::from_secs(10);
|
const BROWSE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
const FORK_INTERVAL: Duration = Duration::from_secs(15 * 60);
|
const FORK_INTERVAL: Duration = Duration::from_secs(15 * 60);
|
||||||
@@ -41,63 +38,42 @@ pub enum GroupsEvent {
|
|||||||
Error(String),
|
Error(String),
|
||||||
Browsed(Box<GroupCandidate>),
|
Browsed(Box<GroupCandidate>),
|
||||||
BrowseSettled(RelayUrl),
|
BrowseSettled(RelayUrl),
|
||||||
|
/// A browsed group announced its member list.
|
||||||
|
Members {
|
||||||
|
key: GroupKey,
|
||||||
|
created_at: Timestamp,
|
||||||
|
members: usize,
|
||||||
|
},
|
||||||
|
/// The discovery synced new groups into the local database.
|
||||||
|
Synced,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
enum Signal {
|
enum Signal {
|
||||||
Event(SubscriptionId, Event),
|
Event(SubscriptionId, Box<Event>),
|
||||||
Eose(SubscriptionId),
|
Eose(SubscriptionId),
|
||||||
Closed(SubscriptionId, String),
|
Closed(SubscriptionId, String),
|
||||||
BrowseSettled(SubscriptionId),
|
BrowseSettled(SubscriptionId),
|
||||||
|
/// A reconciliation started on a relay.
|
||||||
|
Syncing(RelayUrl),
|
||||||
|
/// A relay finished answering a reconciliation.
|
||||||
|
Synced(RelayUrl),
|
||||||
Forks,
|
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 {}
|
impl EventEmitter<GroupsEvent> for GroupsRegistry {}
|
||||||
|
|
||||||
pub struct GroupsRegistry {
|
pub struct GroupsRegistry {
|
||||||
groups: Vec<Entity<Group>>,
|
groups: Vec<Entity<Group>>,
|
||||||
|
previews: HashMap<GroupKey, Entity<Group>>,
|
||||||
list: GroupList,
|
list: GroupList,
|
||||||
browse: HashMap<SubscriptionId, RelayUrl>,
|
browse: HashMap<SubscriptionId, RelayUrl>,
|
||||||
routes: HashMap<SubscriptionId, GroupKey>,
|
routes: HashMap<SubscriptionId, GroupKey>,
|
||||||
observers: HashMap<GroupKey, Subscription>,
|
observers: HashMap<GroupKey, Subscription>,
|
||||||
forks: HashMap<PublicKey, (Timestamp, GroupList)>,
|
forks: HashMap<PublicKey, (Timestamp, GroupList)>,
|
||||||
fork_authors: HashSet<PublicKey>,
|
fork_authors: HashSet<PublicKey>,
|
||||||
|
/// The relays with a discovery reconciliation in flight.
|
||||||
|
syncing: HashSet<RelayUrl>,
|
||||||
signal_tx: flume::Sender<Signal>,
|
signal_tx: flume::Sender<Signal>,
|
||||||
signal_rx: flume::Receiver<Signal>,
|
signal_rx: flume::Receiver<Signal>,
|
||||||
notification_listener: Option<Task<()>>,
|
notification_listener: Option<Task<()>>,
|
||||||
@@ -132,26 +108,27 @@ impl GroupsRegistry {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
cx.defer(move |cx| {
|
cx.defer(move |cx| {
|
||||||
if let Err(error) = this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.handle_notifications(cx);
|
this.handle_notifications(cx);
|
||||||
this.load_cached(cx);
|
this.load_cached(cx);
|
||||||
|
|
||||||
if nostr.read(cx).current_user().is_some() {
|
if nostr.read(cx).current_user().is_some() {
|
||||||
this.load(cx);
|
this.load(cx);
|
||||||
}
|
}
|
||||||
}) {
|
})
|
||||||
log::warn!("nip29: initializing the registry failed: {error}");
|
.ok();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
groups: Vec::new(),
|
groups: Vec::new(),
|
||||||
|
previews: HashMap::new(),
|
||||||
list: GroupList::default(),
|
list: GroupList::default(),
|
||||||
browse: HashMap::new(),
|
browse: HashMap::new(),
|
||||||
routes: HashMap::new(),
|
routes: HashMap::new(),
|
||||||
observers: HashMap::new(),
|
observers: HashMap::new(),
|
||||||
forks: HashMap::new(),
|
forks: HashMap::new(),
|
||||||
fork_authors: HashSet::new(),
|
fork_authors: HashSet::new(),
|
||||||
|
syncing: HashSet::new(),
|
||||||
signal_tx: tx,
|
signal_tx: tx,
|
||||||
signal_rx: rx,
|
signal_rx: rx,
|
||||||
notification_listener: None,
|
notification_listener: None,
|
||||||
@@ -170,6 +147,7 @@ impl GroupsRegistry {
|
|||||||
.iter()
|
.iter()
|
||||||
.find(|group| group.read(cx).key() == key)
|
.find(|group| group.read(cx).key() == key)
|
||||||
.cloned()
|
.cloned()
|
||||||
|
.or_else(|| self.previews.get(key).cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn join(&mut self, reference: GroupReference, cx: &mut Context<Self>) {
|
pub fn join(&mut self, reference: GroupReference, cx: &mut Context<Self>) {
|
||||||
@@ -207,7 +185,7 @@ impl GroupsRegistry {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
log::warn!("nip29: marking the refused join failed: {error}");
|
log::warn!("marking the refused join failed: {error}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -253,22 +231,24 @@ impl GroupsRegistry {
|
|||||||
self.tasks.push(cx.background_spawn(async move {
|
self.tasks.push(cx.background_spawn(async move {
|
||||||
client.add_relay(relay.clone()).and_connect().await.ok();
|
client.add_relay(relay.clone()).and_connect().await.ok();
|
||||||
|
|
||||||
let filter = Filter::new().kind(Kind::GroupMetadata).limit(BROWSE_LIMIT);
|
let metadata = Filter::new().kind(Kind::GroupMetadata).limit(BROWSE_LIMIT);
|
||||||
|
let members = Filter::new().kind(Kind::GroupMembers).limit(BROWSE_LIMIT);
|
||||||
|
|
||||||
let opts = SubscribeAutoCloseOptions::default()
|
let opts = SubscribeAutoCloseOptions::default()
|
||||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||||
.timeout(Some(BROWSE_TIMEOUT));
|
.timeout(Some(BROWSE_TIMEOUT));
|
||||||
|
|
||||||
if let Err(error) = client
|
if let Err(error) = client
|
||||||
.subscribe(ReqTarget::single(relay.clone(), [filter]))
|
.subscribe(ReqTarget::single(relay.clone(), [metadata, members]))
|
||||||
.with_id(id.clone())
|
.with_id(id.clone())
|
||||||
.close_on(opts)
|
.close_on(opts)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
log::warn!("nip29: browsing {relay} failed: {error}");
|
log::warn!("browsing {relay} failed: {error}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(error) = tx.send_async(Signal::BrowseSettled(id)).await {
|
if let Err(error) = tx.send_async(Signal::BrowseSettled(id)).await {
|
||||||
log::warn!("nip29: the browse settle signal was dropped: {error}");
|
log::warn!("the browse settle signal was dropped: {error}");
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -280,6 +260,49 @@ impl GroupsRegistry {
|
|||||||
cx.emit(GroupsEvent::BrowseSettled(relay));
|
cx.emit(GroupsEvent::BrowseSettled(relay));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The (relay, group) pairs of every joined group.
|
||||||
|
fn joined_keys(&self, cx: &App) -> HashSet<(RelayUrl, GroupId)> {
|
||||||
|
self.groups
|
||||||
|
.iter()
|
||||||
|
.map(|group| {
|
||||||
|
let key = group.read(cx).key();
|
||||||
|
(key.relay().clone(), key.id().clone())
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the group is one of the user's groups, not a preview.
|
||||||
|
fn joined(&self, key: &GroupKey, cx: &App) -> bool {
|
||||||
|
self.groups.iter().any(|group| group.read(cx).key() == key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the discoverable groups from the local database only.
|
||||||
|
pub fn cached_discovery(&self, cx: &App) -> Task<Discovery> {
|
||||||
|
let client = NostrRegistry::global(cx).read(cx).client();
|
||||||
|
let joined = self.joined_keys(cx);
|
||||||
|
|
||||||
|
cx.background_spawn(Discovery::cached(client, joined))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconcile the discoverable groups with the relays.
|
||||||
|
pub fn discover(&self, cx: &App) -> Task<()> {
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let client = nostr.read(cx).client();
|
||||||
|
let signer = nostr.read(cx).signer();
|
||||||
|
let joined = self.joined_keys(cx);
|
||||||
|
|
||||||
|
if nostr.read(cx).current_user().is_none() {
|
||||||
|
return Task::ready(());
|
||||||
|
}
|
||||||
|
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
let Ok(me) = signer.get_public_key_async().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
Discovery::default().sync(client, me, joined).await;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn load_more(&mut self, key: &GroupKey, cx: &mut Context<Self>) {
|
pub fn load_more(&mut self, key: &GroupKey, cx: &mut Context<Self>) {
|
||||||
let Some(group) = self.group(key, cx) else {
|
let Some(group) = self.group(key, cx) else {
|
||||||
return;
|
return;
|
||||||
@@ -307,7 +330,7 @@ impl GroupsRegistry {
|
|||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
if let Err(error) = sent.await {
|
if let Err(error) = sent.await {
|
||||||
log::warn!("nip29: leaving {} failed: {error}", key.id());
|
log::warn!("leaving {} failed: {error}", key.id());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
@@ -325,9 +348,11 @@ impl GroupsRegistry {
|
|||||||
let routes = std::mem::take(&mut self.routes);
|
let routes = std::mem::take(&mut self.routes);
|
||||||
|
|
||||||
self.groups.clear();
|
self.groups.clear();
|
||||||
|
self.previews.clear();
|
||||||
self.observers.clear();
|
self.observers.clear();
|
||||||
self.forks.clear();
|
self.forks.clear();
|
||||||
self.fork_authors.clear();
|
self.fork_authors.clear();
|
||||||
|
self.syncing.clear();
|
||||||
self.list = GroupList::default();
|
self.list = GroupList::default();
|
||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
@@ -343,7 +368,7 @@ impl GroupsRegistry {
|
|||||||
self.tasks.push(cx.background_spawn(async move {
|
self.tasks.push(cx.background_spawn(async move {
|
||||||
for id in ids {
|
for id in ids {
|
||||||
if let Err(error) = client.unsubscribe(&id).await {
|
if let Err(error) = client.unsubscribe(&id).await {
|
||||||
log::warn!("nip29: unsubscribing {id} failed: {error}");
|
log::warn!("unsubscribing {id} failed: {error}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -375,11 +400,14 @@ impl GroupsRegistry {
|
|||||||
self.tasks
|
self.tasks
|
||||||
.push(cx.spawn(async move |this, cx| match task.await {
|
.push(cx.spawn(async move |this, cx| match task.await {
|
||||||
Ok(list) => {
|
Ok(list) => {
|
||||||
if let Err(error) = this.update(cx, |this, cx| this.merge(list, cx)) {
|
this.update(cx, |this, cx| {
|
||||||
log::warn!("nip29: applying the cached group list failed: {error}");
|
this.merge(list, cx);
|
||||||
}
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("loading the cached group list failed: {error}");
|
||||||
}
|
}
|
||||||
Err(error) => log::warn!("nip29: loading the cached group list failed: {error}"),
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,7 +416,7 @@ impl GroupsRegistry {
|
|||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
let signer = nostr.read(cx).signer();
|
||||||
|
|
||||||
let updates: Task<Result<(), Error>> = cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let me = signer.get_public_key_async().await?;
|
let me = signer.get_public_key_async().await?;
|
||||||
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
|
||||||
let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1);
|
let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1);
|
||||||
@@ -399,14 +427,9 @@ impl GroupsRegistry {
|
|||||||
.close_on(opts)
|
.close_on(opts)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok::<(), anyhow::Error>(())
|
||||||
});
|
})
|
||||||
|
.detach();
|
||||||
self.tasks.push(cx.spawn(async move |_this, _cx| {
|
|
||||||
if let Err(error) = updates.await {
|
|
||||||
log::warn!("nip29: subscribing to the group list failed: {error}");
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
self.watch_forks(cx);
|
self.watch_forks(cx);
|
||||||
}
|
}
|
||||||
@@ -430,7 +453,6 @@ impl GroupsRegistry {
|
|||||||
self.tasks.push(cx.background_spawn(async move {
|
self.tasks.push(cx.background_spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
executor.timer(FORK_INTERVAL).await;
|
executor.timer(FORK_INTERVAL).await;
|
||||||
|
|
||||||
if tx.send_async(Signal::Forks).await.is_err() {
|
if tx.send_async(Signal::Forks).await.is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -471,14 +493,14 @@ impl GroupsRegistry {
|
|||||||
.with_id(fork_subscription_id())
|
.with_id(fork_subscription_id())
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
log::warn!("nip29: subscribing to the admins' group lists failed: {error}");
|
log::warn!("subscribing to the admins' group lists failed: {error}");
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn absorb_fork_list(&mut self, event: &Event, cx: &mut Context<Self>) {
|
fn absorb_fork_list(&mut self, event: &Event, cx: &mut Context<Self>) {
|
||||||
let Ok(list) = GroupList::parse(event) else {
|
let Ok(list) = GroupList::parse(event) else {
|
||||||
log::warn!("nip29: ignoring a malformed admin group list");
|
log::warn!("ignoring a malformed admin group list");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -519,6 +541,12 @@ impl GroupsRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn ensure(&mut self, key: GroupKey, cx: &mut Context<Self>) {
|
fn ensure(&mut self, key: GroupKey, cx: &mut Context<Self>) {
|
||||||
|
// A previewed group becomes one of the user's groups, keeping its state.
|
||||||
|
if let Some(group) = self.previews.remove(&key) {
|
||||||
|
self.groups.push(group);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if self.group(&key, cx).is_some() {
|
if self.group(&key, cx).is_some() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -526,9 +554,31 @@ impl GroupsRegistry {
|
|||||||
let group = cx.new(|_| Group::new(key.clone()));
|
let group = cx.new(|_| Group::new(key.clone()));
|
||||||
self.groups.push(group.clone());
|
self.groups.push(group.clone());
|
||||||
|
|
||||||
|
self.observe_group(key.clone(), &group, cx);
|
||||||
|
self.subscribe(key.clone(), cx);
|
||||||
|
self.load_state(key, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Track a group opened for preview: connected, but not joined.
|
||||||
|
pub fn open(&mut self, key: GroupKey, cx: &mut Context<Self>) -> Entity<Group> {
|
||||||
|
if let Some(group) = self.group(&key, cx) {
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
let group = cx.new(|_| Group::new(key.clone()));
|
||||||
|
self.previews.insert(key.clone(), group.clone());
|
||||||
|
|
||||||
|
self.observe_group(key.clone(), &group, cx);
|
||||||
|
self.subscribe(key.clone(), cx);
|
||||||
|
self.load_state(key, cx);
|
||||||
|
|
||||||
|
group
|
||||||
|
}
|
||||||
|
|
||||||
|
fn observe_group(&mut self, key: GroupKey, group: &Entity<Group>, cx: &mut Context<Self>) {
|
||||||
self.observers.insert(
|
self.observers.insert(
|
||||||
key.clone(),
|
key,
|
||||||
cx.subscribe(&group, |this, group, event, cx| match event {
|
cx.subscribe(group, |this, group, event, cx| match event {
|
||||||
GroupEvent::Sent(event) => {
|
GroupEvent::Sent(event) => {
|
||||||
if event.kind == Kind::GroupDeleteEvent {
|
if event.kind == Kind::GroupDeleteEvent {
|
||||||
this.forget_event(group, event.clone(), cx);
|
this.forget_event(group, event.clone(), cx);
|
||||||
@@ -538,7 +588,13 @@ impl GroupsRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
GroupEvent::Updated => {
|
GroupEvent::Updated => {
|
||||||
this.refresh_entry(group, cx);
|
let key = group.read(cx).key().clone();
|
||||||
|
|
||||||
|
// Only the user's groups belong in their group list.
|
||||||
|
if this.joined(&key, cx) {
|
||||||
|
this.refresh_entry(group, cx);
|
||||||
|
}
|
||||||
|
|
||||||
this.sync_forks(false, cx);
|
this.sync_forks(false, cx);
|
||||||
|
|
||||||
cx.emit(GroupsEvent::Updated);
|
cx.emit(GroupsEvent::Updated);
|
||||||
@@ -547,9 +603,6 @@ impl GroupsRegistry {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
self.subscribe(key.clone(), cx);
|
|
||||||
self.load_state(key, cx);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn subscribe(&mut self, key: GroupKey, cx: &mut Context<Self>) {
|
fn subscribe(&mut self, key: GroupKey, cx: &mut Context<Self>) {
|
||||||
@@ -573,6 +626,7 @@ impl GroupsRegistry {
|
|||||||
self.routes.remove(&page);
|
self.routes.remove(&page);
|
||||||
self.observers.remove(&key);
|
self.observers.remove(&key);
|
||||||
self.groups.retain(|group| group.read(cx).key() != &key);
|
self.groups.retain(|group| group.read(cx).key() != &key);
|
||||||
|
self.previews.remove(&key);
|
||||||
self.list.remove(&key);
|
self.list.remove(&key);
|
||||||
|
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
@@ -581,7 +635,7 @@ impl GroupsRegistry {
|
|||||||
self.tasks.push(cx.background_spawn(async move {
|
self.tasks.push(cx.background_spawn(async move {
|
||||||
for id in [id, page] {
|
for id in [id, page] {
|
||||||
if let Err(error) = client.unsubscribe(&id).await {
|
if let Err(error) = client.unsubscribe(&id).await {
|
||||||
log::warn!("nip29: unsubscribing {id} failed: {error}");
|
log::warn!("unsubscribing {id} failed: {error}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,7 +644,7 @@ impl GroupsRegistry {
|
|||||||
.custom_tag(SingleLetterTag::LOWERCASE_R, cache_tag);
|
.custom_tag(SingleLetterTag::LOWERCASE_R, cache_tag);
|
||||||
|
|
||||||
if let Err(error) = client.database().delete(filter).await {
|
if let Err(error) = client.database().delete(filter).await {
|
||||||
log::warn!("nip29: clearing the group cache failed: {error}");
|
log::warn!("clearing the group cache failed: {error}");
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -651,18 +705,17 @@ impl GroupsRegistry {
|
|||||||
let event = match builder.finalize_async(&signer).await {
|
let event = match builder.finalize_async(&signer).await {
|
||||||
Ok(event) => event,
|
Ok(event) => event,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
log::warn!("nip29: signing the group list failed: {error}");
|
log::warn!("signing the group list failed: {error}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
match client.send_event(&event).to_nip65().await {
|
match client.send_event(&event).to_nip65().await {
|
||||||
Ok(output) if output.failed.is_empty() => {}
|
Ok(output) if output.failed.is_empty() => {}
|
||||||
Ok(output) => log::warn!(
|
Ok(output) => {
|
||||||
"nip29: {} relay(s) rejected the group list",
|
log::warn!("{} relay(s) rejected the group list", output.failed.len())
|
||||||
output.failed.len()
|
}
|
||||||
),
|
Err(error) => log::warn!("publishing the group list failed: {error}"),
|
||||||
Err(error) => log::warn!("nip29: publishing the group list failed: {error}"),
|
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -687,7 +740,7 @@ impl GroupsRegistry {
|
|||||||
.read_with(cx, |group, cx| group.save_envelope(event, cx))
|
.read_with(cx, |group, cx| group.save_envelope(event, cx))
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
log::warn!("nip29: caching failed: {error}");
|
log::warn!("caching failed: {error}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,7 +751,7 @@ impl GroupsRegistry {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
log::warn!("nip29: reloading {} failed: {error}", logged.id());
|
log::warn!("reloading {} failed: {error}", logged.id());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -725,7 +778,7 @@ impl GroupsRegistry {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
log::warn!("nip29: loading {} state failed: {error}", logged.id());
|
log::warn!("loading {} state failed: {error}", logged.id());
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -745,7 +798,7 @@ impl GroupsRegistry {
|
|||||||
.read_with(cx, |group, cx| group.save_envelope(event.clone(), cx))
|
.read_with(cx, |group, cx| group.save_envelope(event.clone(), cx))
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
log::warn!("nip29: caching failed: {error}");
|
log::warn!("caching failed: {error}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -791,7 +844,7 @@ impl GroupsRegistry {
|
|||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
if let Err(error) = purge.await {
|
if let Err(error) = purge.await {
|
||||||
log::warn!("nip29: dropping the deleted envelope failed: {error}");
|
log::warn!("dropping the deleted envelope failed: {error}");
|
||||||
}
|
}
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
group.update(cx, |group, cx| {
|
group.update(cx, |group, cx| {
|
||||||
@@ -807,7 +860,7 @@ impl GroupsRegistry {
|
|||||||
if id == list_subscription_id() {
|
if id == list_subscription_id() {
|
||||||
match GroupList::parse(&event) {
|
match GroupList::parse(&event) {
|
||||||
Ok(list) => self.merge(list, cx),
|
Ok(list) => self.merge(list, cx),
|
||||||
Err(error) => log::warn!("nip29: ignoring a malformed group list: {error}"),
|
Err(error) => log::warn!("ignoring a malformed group list: {error}"),
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -842,8 +895,17 @@ impl GroupsRegistry {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if let Some((group, created_at, members)) = parse_members(event) {
|
||||||
|
cx.emit(GroupsEvent::Members {
|
||||||
|
key: GroupKey::new(relay, group),
|
||||||
|
created_at,
|
||||||
|
members,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let Ok(metadata) = GroupMetadata::parse(event) else {
|
let Ok(metadata) = GroupMetadata::parse(event) else {
|
||||||
log::warn!("nip29: ignoring malformed group metadata from {relay}");
|
log::warn!("ignoring malformed group metadata from {relay}");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -853,6 +915,7 @@ impl GroupsRegistry {
|
|||||||
|
|
||||||
cx.emit(GroupsEvent::Browsed(Box::new(GroupCandidate {
|
cx.emit(GroupsEvent::Browsed(Box::new(GroupCandidate {
|
||||||
key: GroupKey::new(relay, group),
|
key: GroupKey::new(relay, group),
|
||||||
|
created_at: event.created_at,
|
||||||
metadata,
|
metadata,
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
@@ -887,12 +950,12 @@ impl GroupsRegistry {
|
|||||||
|
|
||||||
fn handle_closed(&mut self, id: SubscriptionId, reason: String, cx: &mut Context<Self>) {
|
fn handle_closed(&mut self, id: SubscriptionId, reason: String, cx: &mut Context<Self>) {
|
||||||
if let Some(relay) = self.browse.get(&id) {
|
if let Some(relay) = self.browse.get(&id) {
|
||||||
log::warn!("nip29: browsing {relay} was refused: {reason}");
|
log::warn!("browsing {relay} was refused: {reason}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if id == fork_subscription_id() {
|
if id == fork_subscription_id() {
|
||||||
log::debug!("nip29: the admins' group lists were refused: {reason}");
|
log::debug!("the admins' group lists were refused: {reason}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -903,7 +966,7 @@ impl GroupsRegistry {
|
|||||||
if is_page(&id) {
|
if is_page(&id) {
|
||||||
self.routes.remove(&id);
|
self.routes.remove(&id);
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"nip29: loading older messages in {} was refused: {reason}",
|
"loading older messages in {} was refused: {reason}",
|
||||||
key.id()
|
key.id()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -943,44 +1006,46 @@ impl GroupsRegistry {
|
|||||||
const MAX_PROCESSED: usize = 10_000;
|
const MAX_PROCESSED: usize = 10_000;
|
||||||
|
|
||||||
while let Some(notification) = notifications.next().await {
|
while let Some(notification) = notifications.next().await {
|
||||||
let ClientNotification::Message { message, .. } = notification else {
|
let ClientNotification::Message { relay_url, message } = notification else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
let signal = match *message {
|
match *message {
|
||||||
RelayMessage::Event {
|
RelayMessage::Event {
|
||||||
subscription_id,
|
subscription_id,
|
||||||
event,
|
event,
|
||||||
} => {
|
} if is_own_subscription(&subscription_id) => {
|
||||||
let id = subscription_id.into_owned();
|
if processed.len() >= MAX_PROCESSED {
|
||||||
|
processed.clear();
|
||||||
|
}
|
||||||
|
|
||||||
if !is_own_subscription(&id) {
|
if processed.insert(event.id) {
|
||||||
None
|
let signal = Signal::Event(
|
||||||
} else {
|
subscription_id.into_owned(),
|
||||||
if processed.len() >= MAX_PROCESSED {
|
Box::new(event.into_owned()),
|
||||||
processed.clear();
|
);
|
||||||
}
|
|
||||||
processed
|
tx.send_async(signal).await.ok();
|
||||||
.insert(event.id)
|
|
||||||
.then(|| Signal::Event(id, event.into_owned()))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
RelayMessage::EndOfStoredEvents(id) => {
|
RelayMessage::EndOfStoredEvents(id) if is_own_subscription(&id) => {
|
||||||
let id = id.into_owned();
|
tx.send_async(Signal::Eose(id.into_owned())).await.ok();
|
||||||
is_own_subscription(&id).then_some(Signal::Eose(id))
|
|
||||||
}
|
}
|
||||||
RelayMessage::Closed {
|
RelayMessage::Closed {
|
||||||
subscription_id,
|
subscription_id,
|
||||||
message,
|
message,
|
||||||
} => {
|
} if is_own_subscription(&subscription_id) => {
|
||||||
let id = subscription_id.into_owned();
|
let signal =
|
||||||
is_own_subscription(&id).then_some(Signal::Closed(id, message.into_owned()))
|
Signal::Closed(subscription_id.into_owned(), message.into_owned());
|
||||||
|
tx.send_async(signal).await.ok();
|
||||||
}
|
}
|
||||||
_ => None,
|
RelayMessage::NegMsg { .. } => {
|
||||||
};
|
tx.send_async(Signal::Syncing(relay_url)).await.ok();
|
||||||
|
}
|
||||||
if let Some(signal) = signal {
|
RelayMessage::NegErr { .. } | RelayMessage::EndOfStoredEvents(_) => {
|
||||||
tx.send_async(signal).await.ok();
|
tx.send_async(Signal::Synced(relay_url)).await.ok();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -989,7 +1054,7 @@ impl GroupsRegistry {
|
|||||||
while let Ok(signal) = rx.recv_async().await {
|
while let Ok(signal) = rx.recv_async().await {
|
||||||
this.update(cx, |this, cx| match signal {
|
this.update(cx, |this, cx| match signal {
|
||||||
Signal::Event(id, event) => {
|
Signal::Event(id, event) => {
|
||||||
this.handle_event(id, event, cx);
|
this.handle_event(id, *event, cx);
|
||||||
}
|
}
|
||||||
Signal::Eose(id) => {
|
Signal::Eose(id) => {
|
||||||
this.handle_eose(id, cx);
|
this.handle_eose(id, cx);
|
||||||
@@ -1000,6 +1065,14 @@ impl GroupsRegistry {
|
|||||||
Signal::BrowseSettled(id) => {
|
Signal::BrowseSettled(id) => {
|
||||||
this.settle_browse(id, cx);
|
this.settle_browse(id, cx);
|
||||||
}
|
}
|
||||||
|
Signal::Syncing(relay) => {
|
||||||
|
this.syncing.insert(relay);
|
||||||
|
}
|
||||||
|
Signal::Synced(relay) => {
|
||||||
|
if this.syncing.remove(&relay) {
|
||||||
|
cx.emit(GroupsEvent::Synced);
|
||||||
|
}
|
||||||
|
}
|
||||||
Signal::Forks => {
|
Signal::Forks => {
|
||||||
this.sync_forks(true, cx);
|
this.sync_forks(true, cx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -335,6 +335,17 @@ impl GroupMetadata {
|
|||||||
.identifier()
|
.identifier()
|
||||||
.ok_or_else(|| anyhow!("group metadata has no d tag"))?;
|
.ok_or_else(|| anyhow!("group metadata has no d tag"))?;
|
||||||
|
|
||||||
|
let content: serde_json::Value =
|
||||||
|
serde_json::from_str(event.content.as_str()).unwrap_or(serde_json::Value::Null);
|
||||||
|
|
||||||
|
let field = |key: &str| -> Option<String> {
|
||||||
|
content
|
||||||
|
.get(key)
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.map(str::to_owned)
|
||||||
|
.or_else(|| tag_value(&event.tags, key).map(str::to_owned))
|
||||||
|
};
|
||||||
|
|
||||||
let mut supported_kinds: Option<Vec<Kind>> = None;
|
let mut supported_kinds: Option<Vec<Kind>> = None;
|
||||||
|
|
||||||
for tag in event
|
for tag in event
|
||||||
@@ -363,10 +374,10 @@ impl GroupMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
name: tag_value(&event.tags, "name").map(str::to_owned),
|
name: field("name"),
|
||||||
picture: tag_value(&event.tags, "picture").map(str::to_owned),
|
picture: field("picture"),
|
||||||
banner: tag_value(&event.tags, "banner").map(str::to_owned),
|
banner: field("banner"),
|
||||||
about: tag_value(&event.tags, "about").map(str::to_owned),
|
about: field("about"),
|
||||||
private: has_tag(&event.tags, "private"),
|
private: has_tag(&event.tags, "private"),
|
||||||
restricted: has_tag(&event.tags, "restricted"),
|
restricted: has_tag(&event.tags, "restricted"),
|
||||||
hidden: has_tag(&event.tags, "hidden"),
|
hidden: has_tag(&event.tags, "hidden"),
|
||||||
@@ -412,6 +423,8 @@ impl GroupMetadata {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct GroupCandidate {
|
pub struct GroupCandidate {
|
||||||
pub key: GroupKey,
|
pub key: GroupKey,
|
||||||
|
/// The timestamp of the metadata event the candidate was built from.
|
||||||
|
pub(crate) created_at: Timestamp,
|
||||||
pub(crate) metadata: GroupMetadata,
|
pub(crate) metadata: GroupMetadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,6 +438,10 @@ impl GroupCandidate {
|
|||||||
pub fn display_image(&self) -> Option<&str> {
|
pub fn display_image(&self) -> Option<&str> {
|
||||||
self.metadata.picture.as_deref()
|
self.metadata.picture.as_deref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn display_about(&self) -> Option<&str> {
|
||||||
|
self.metadata.about.as_deref()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
|
use crate::{GroupId, GroupKey};
|
||||||
|
|
||||||
|
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-";
|
||||||
|
|
||||||
|
pub(crate) fn list_subscription_id() -> SubscriptionId {
|
||||||
|
SubscriptionId::new(LIST_SUBSCRIPTION)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn fork_subscription_id() -> SubscriptionId {
|
||||||
|
SubscriptionId::new(FORK_SUBSCRIPTION)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn subscription_id(key: &GroupKey) -> SubscriptionId {
|
||||||
|
SubscriptionId::new(format!("{GROUP_PREFIX}{:08x}", key.uniq_id() as u32))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn page_subscription(key: &GroupKey) -> SubscriptionId {
|
||||||
|
SubscriptionId::new(format!("{PAGE_PREFIX}{:08x}", key.uniq_id() as u32))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_page(id: &SubscriptionId) -> bool {
|
||||||
|
id.as_str().starts_with(PAGE_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_members(event: &Event) -> Option<(GroupId, Timestamp, usize)> {
|
||||||
|
if event.kind != Kind::GroupMembers {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = GroupId::new(event.tags.identifier()?).ok()?;
|
||||||
|
let members = event.tags.public_keys().collect::<HashSet<_>>().len();
|
||||||
|
|
||||||
|
Some((id, event.created_at, members))
|
||||||
|
}
|
||||||
+47
-56
@@ -9,7 +9,7 @@ use gpui::{
|
|||||||
PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div,
|
PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div,
|
||||||
list, px,
|
list, px,
|
||||||
};
|
};
|
||||||
use nip29::{Group, GroupEvent, GroupsRegistry, Membership, Pin, Row};
|
use nip29::{Group, GroupEvent, GroupKey, GroupsRegistry, Membership, Pin, Row};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -64,33 +64,8 @@ enum Notice {
|
|||||||
|
|
||||||
impl Notice {
|
impl Notice {
|
||||||
fn of(group: &Group) -> Option<Self> {
|
fn of(group: &Group) -> Option<Self> {
|
||||||
if let Some(reason) = group.refused() {
|
let reason = group.refused()?;
|
||||||
return Some(Self::Relay(reason.to_owned()));
|
Some(Self::Relay(reason.to_owned()))
|
||||||
}
|
|
||||||
|
|
||||||
let closed = group.metadata().is_some_and(|metadata| metadata.closed);
|
|
||||||
|
|
||||||
match group.membership() {
|
|
||||||
Membership::Member => {}
|
|
||||||
Membership::Pending { .. } => return Some(Self::Pending),
|
|
||||||
Membership::Refused { reason } => {
|
|
||||||
return Some(Self::Join {
|
|
||||||
refused: reason.clone(),
|
|
||||||
closed,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Membership::Unknown | Membership::Removed => {
|
|
||||||
return Some(Self::Join {
|
|
||||||
refused: None,
|
|
||||||
closed,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
group
|
|
||||||
.metadata()
|
|
||||||
.is_some_and(|metadata| !metadata.supports(Kind::ChatMessage))
|
|
||||||
.then_some(Self::Unsupported)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -654,12 +629,8 @@ impl GroupPanel {
|
|||||||
Button::new("group-moved")
|
Button::new("group-moved")
|
||||||
.label("Join")
|
.label("Join")
|
||||||
.small()
|
.small()
|
||||||
.on_click(cx.listener(move |_this, _event, _window, cx| {
|
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||||
let registry = GroupsRegistry::global(cx);
|
this.request_join(moved.clone(), cx);
|
||||||
|
|
||||||
registry.update(cx, |registry, cx| {
|
|
||||||
registry.join_key(moved.clone(), None, cx);
|
|
||||||
});
|
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
@@ -758,6 +729,13 @@ impl GroupPanel {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ask the relay to admit the user into the group.
|
||||||
|
fn request_join(&self, key: GroupKey, cx: &mut App) {
|
||||||
|
GroupsRegistry::global(cx).update(cx, |registry, cx| {
|
||||||
|
registry.join_key(key, None, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn render_notice(&self, notice: &Notice, cx: &mut Context<Self>) -> AnyElement {
|
fn render_notice(&self, notice: &Notice, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let row = h_flex()
|
let row = h_flex()
|
||||||
.w_full()
|
.w_full()
|
||||||
@@ -767,7 +745,7 @@ impl GroupPanel {
|
|||||||
.px_3()
|
.px_3()
|
||||||
.py_2()
|
.py_2()
|
||||||
.text_sm()
|
.text_sm()
|
||||||
.text_color(cx.theme().text_placeholder);
|
.text_color(cx.theme().text_muted);
|
||||||
|
|
||||||
match notice {
|
match notice {
|
||||||
Notice::Relay(reason) => row
|
Notice::Relay(reason) => row
|
||||||
@@ -781,7 +759,7 @@ impl GroupPanel {
|
|||||||
|
|
||||||
if *closed {
|
if *closed {
|
||||||
return row
|
return row
|
||||||
.child("This group is closed; an invite link is required.")
|
.child("Group is closed. An invite link is required.")
|
||||||
.into_any_element();
|
.into_any_element();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -793,17 +771,17 @@ impl GroupPanel {
|
|||||||
return row.into_any_element();
|
return row.into_any_element();
|
||||||
};
|
};
|
||||||
|
|
||||||
row.child(
|
row.child("You're in preview mode. Join this group to talk.")
|
||||||
Button::new("join-group")
|
.child(
|
||||||
.label("Request to join")
|
Button::new("join")
|
||||||
.small()
|
.label("Request to Join")
|
||||||
.on_click(cx.listener(move |_this, _event, _window, cx| {
|
.small()
|
||||||
GroupsRegistry::global(cx).update(cx, |registry, cx| {
|
.ghost_alt()
|
||||||
registry.join_key(key.clone(), None, cx);
|
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||||
});
|
this.request_join(key.clone(), cx);
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
Notice::Pending => row
|
Notice::Pending => row
|
||||||
.child("Waiting for the relay to approve.")
|
.child("Waiting for the relay to approve.")
|
||||||
@@ -871,18 +849,31 @@ impl GroupPanel {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_composer(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render_composer(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let can_send = self.group.upgrade().is_some_and(|group| {
|
let notice = self.group.upgrade().and_then(|group| {
|
||||||
let group = group.read(cx);
|
let group = group.read(cx);
|
||||||
let metadata = group.metadata();
|
|
||||||
let supports_text =
|
|
||||||
metadata.is_none_or(|metadata| metadata.supports(Kind::ChatMessage));
|
|
||||||
let writable = metadata.is_none_or(|metadata| !metadata.restricted)
|
|
||||||
|| matches!(group.membership(), Membership::Member);
|
|
||||||
|
|
||||||
supports_text && writable
|
if !group
|
||||||
|
.metadata()
|
||||||
|
.is_none_or(|metadata| metadata.supports(Kind::ChatMessage))
|
||||||
|
{
|
||||||
|
return Some(Notice::Unsupported);
|
||||||
|
}
|
||||||
|
|
||||||
|
match group.membership() {
|
||||||
|
Membership::Member => None,
|
||||||
|
Membership::Pending { .. } => Some(Notice::Pending),
|
||||||
|
_ => Some(Notice::Join {
|
||||||
|
refused: None,
|
||||||
|
closed: group.metadata().is_some_and(|metadata| metadata.closed),
|
||||||
|
}),
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if let Some(notice) = notice {
|
||||||
|
return self.render_notice(¬ice, cx);
|
||||||
|
}
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
.w_full()
|
.w_full()
|
||||||
@@ -912,12 +903,12 @@ impl GroupPanel {
|
|||||||
.tooltip("Send")
|
.tooltip("Send")
|
||||||
.ghost()
|
.ghost()
|
||||||
.large()
|
.large()
|
||||||
.disabled(!can_send)
|
|
||||||
.on_click(cx.listener(|this, _event, window, cx| {
|
.on_click(cx.listener(|this, _event, window, cx| {
|
||||||
this.send(window, cx);
|
this.send(window, cx);
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_attachments(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
|
fn render_attachments(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
|
||||||
|
|||||||
+13
-18
@@ -30,9 +30,6 @@ pub use nip05::*;
|
|||||||
pub use signer::{CoopAuthUrlHandler, UniversalSigner};
|
pub use signer::{CoopAuthUrlHandler, UniversalSigner};
|
||||||
|
|
||||||
pub fn init(cx: &mut App, cli_key: Option<SecretKey>) {
|
pub fn init(cx: &mut App, cli_key: Option<SecretKey>) {
|
||||||
// rustls uses the `aws_lc_rs` provider by default
|
|
||||||
// This only errors if the default provider has already
|
|
||||||
// been installed. We can ignore this `Result`.
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
rustls::crypto::aws_lc_rs::default_provider()
|
rustls::crypto::aws_lc_rs::default_provider()
|
||||||
.install_default()
|
.install_default()
|
||||||
@@ -136,22 +133,20 @@ impl NostrRegistry {
|
|||||||
|
|
||||||
// Connect to bootstrap relays once the registry has been returned to the app
|
// Connect to bootstrap relays once the registry has been returned to the app
|
||||||
cx.defer(move |cx| {
|
cx.defer(move |cx| {
|
||||||
entity
|
let _ = entity.update(cx, |this, cx| {
|
||||||
.update(cx, |this, cx| {
|
this.connect_bootstrap_relays(cx);
|
||||||
this.connect_bootstrap_relays(cx);
|
|
||||||
|
|
||||||
if cfg!(target_arch = "wasm32") {
|
if cfg!(target_arch = "wasm32") {
|
||||||
this.mark_ready(cx);
|
this.mark_ready(cx);
|
||||||
cx.emit(StateEvent::NoSigner);
|
cx.emit(StateEvent::NoSigner);
|
||||||
} else if let Some(secret) = cli_key {
|
} else if let Some(secret) = cli_key {
|
||||||
// Use CLI-provided key -- same path as get_user_credential
|
// Use CLI-provided key -- same path as get_user_credential
|
||||||
let keys = Keys::new(secret);
|
let keys = Keys::new(secret);
|
||||||
this.set_signer(keys, cx);
|
this.set_signer(keys, cx);
|
||||||
} else {
|
} else {
|
||||||
this.get_user_credential(cx);
|
this.get_user_credential(cx);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
.ok();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ person = { path = "../person" }
|
|||||||
auto_update = { path = "../auto_update" }
|
auto_update = { path = "../auto_update" }
|
||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
|
gpui-base.workspace = true
|
||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
instant.workspace = true
|
instant.workspace = true
|
||||||
nostr-connect.workspace = true
|
nostr-connect.workspace = true
|
||||||
|
|||||||
@@ -1,26 +1,32 @@
|
|||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||||
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, WeakEntity, Window,
|
InteractiveElement, IntoElement, ParentElement, Render, ScrollStrategy, SharedString,
|
||||||
div, uniform_list,
|
StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, Window, div, px, relative,
|
||||||
|
size,
|
||||||
};
|
};
|
||||||
use nip29::{GroupCandidate, GroupKey, GroupsEvent, GroupsRegistry};
|
use gpui_base::{VirtualListScrollHandle, v_virtual_list};
|
||||||
use nostr_sdk::prelude::RelayUrl;
|
use nip29::{Discovery, GroupCandidate, GroupKey, GroupsEvent, GroupsRegistry, Membership};
|
||||||
|
use nostr_sdk::prelude::{RelayUrl, Timestamp};
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
|
use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
|
||||||
use ui::indicator::Indicator;
|
|
||||||
use ui::input::{Input, InputEvent, InputState};
|
use ui::input::{Input, InputEvent, InputState};
|
||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
use ui::scroll::Scrollbar;
|
||||||
|
use ui::{Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||||
|
|
||||||
const TITLE: &str = "Browse";
|
const TITLE: &str = "Browse";
|
||||||
const INPUT_PLACEHOLDER: &str = "wss://relay.example.com";
|
const INPUT_PLACEHOLDER: &str = "wss://relay.example.com";
|
||||||
|
const HEADER_HEIGHT: f32 = 96.0;
|
||||||
|
const CARD_HEIGHT: f32 = 164.0;
|
||||||
|
const NO_DESCRIPTION: &str = "No description";
|
||||||
|
|
||||||
pub fn init(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) -> Entity<BrowsePanel> {
|
pub fn init(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) -> Entity<BrowsePanel> {
|
||||||
cx.new(|cx| BrowsePanel::new(dock, window, cx))
|
cx.new(|cx| BrowsePanel::new(dock, window, cx))
|
||||||
@@ -35,8 +41,15 @@ pub struct BrowsePanel {
|
|||||||
relay: Option<RelayUrl>,
|
relay: Option<RelayUrl>,
|
||||||
/// The groups the relay has announced so far
|
/// The groups the relay has announced so far
|
||||||
candidates: Vec<GroupCandidate>,
|
candidates: Vec<GroupCandidate>,
|
||||||
|
/// The groups discovered from the cache and the relays
|
||||||
|
discovery: Discovery,
|
||||||
|
/// Whether the discovery sync is still running
|
||||||
|
discovering: bool,
|
||||||
|
/// The scroll position of the panel
|
||||||
|
scroll_handle: VirtualListScrollHandle,
|
||||||
/// Whether the relay is still answering
|
/// Whether the relay is still answering
|
||||||
loading: bool,
|
loading: bool,
|
||||||
|
tasks: SmallVec<[Task<()>; 2]>,
|
||||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,21 +76,102 @@ impl BrowsePanel {
|
|||||||
GroupsEvent::BrowseSettled(relay) => {
|
GroupsEvent::BrowseSettled(relay) => {
|
||||||
this.settle(relay.clone(), cx);
|
this.settle(relay.clone(), cx);
|
||||||
}
|
}
|
||||||
|
GroupsEvent::Synced => {
|
||||||
|
this.refresh_discovery(cx);
|
||||||
|
}
|
||||||
|
GroupsEvent::Members {
|
||||||
|
key,
|
||||||
|
created_at,
|
||||||
|
members,
|
||||||
|
} => {
|
||||||
|
this.push_members(key.clone(), *created_at, *members, cx);
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
cx.defer_in(window, |this, _window, cx| {
|
||||||
|
this.load_cached(cx);
|
||||||
|
this.sync_discovery(cx);
|
||||||
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
dock,
|
dock,
|
||||||
input,
|
input,
|
||||||
relay: None,
|
relay: None,
|
||||||
candidates: Vec::new(),
|
candidates: Vec::new(),
|
||||||
|
discovery: Discovery::default(),
|
||||||
|
discovering: true,
|
||||||
|
scroll_handle: VirtualListScrollHandle::new(),
|
||||||
loading: false,
|
loading: false,
|
||||||
|
tasks: smallvec![],
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load the groups from the local database.
|
||||||
|
fn load_cached(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let registry = GroupsRegistry::global(cx);
|
||||||
|
let discovery = registry.read(cx).cached_discovery(cx);
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
let discovery = discovery.await;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.discovery = discovery;
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync the discoverable groups with the relays.
|
||||||
|
fn sync_discovery(&mut self, cx: &mut Context<Self>) {
|
||||||
|
self.discovering = true;
|
||||||
|
|
||||||
|
let registry = GroupsRegistry::global(cx);
|
||||||
|
let sync = registry.read(cx).discover(cx);
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
sync.await;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.discovering = false;
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A browsed group announced its member list.
|
||||||
|
fn push_members(
|
||||||
|
&mut self,
|
||||||
|
key: GroupKey,
|
||||||
|
created_at: Timestamp,
|
||||||
|
members: usize,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
self.discovery.set_members(key, created_at, members);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The discovery synced new groups: re-query the local database.
|
||||||
|
fn refresh_discovery(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let registry = GroupsRegistry::global(cx);
|
||||||
|
let discovery = registry.read(cx).cached_discovery(cx);
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
let discovery = discovery.await;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.discovery = discovery;
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/// Browse the relay in the input.
|
/// Browse the relay in the input.
|
||||||
fn browse(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn browse(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let registry = GroupsRegistry::global(cx);
|
let registry = GroupsRegistry::global(cx);
|
||||||
@@ -98,6 +192,7 @@ impl BrowsePanel {
|
|||||||
self.relay = Some(relay.clone());
|
self.relay = Some(relay.clone());
|
||||||
self.candidates.clear();
|
self.candidates.clear();
|
||||||
self.loading = true;
|
self.loading = true;
|
||||||
|
self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
|
||||||
|
|
||||||
registry.update(cx, |registry, cx| {
|
registry.update(cx, |registry, cx| {
|
||||||
registry.browse(relay, cx);
|
registry.browse(relay, cx);
|
||||||
@@ -128,21 +223,13 @@ impl BrowsePanel {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Join a browsed group and open it.
|
/// Open the group's panel to preview (or revisit) it.
|
||||||
fn join(&self, key: GroupKey, window: &mut Window, cx: &mut App) {
|
fn open_panel(dock: &WeakEntity<DockArea>, key: GroupKey, window: &mut Window, cx: &mut App) {
|
||||||
let dock = self.dock.clone();
|
|
||||||
let registry = GroupsRegistry::global(cx);
|
let registry = GroupsRegistry::global(cx);
|
||||||
|
let group = registry.update(cx, |registry, cx| registry.open(key, 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(
|
ui::dock::add_panel_to(
|
||||||
&dock,
|
dock,
|
||||||
PanelHandle::new(nip29_ui::init(group, window, cx)),
|
PanelHandle::new(nip29_ui::init(group, window, cx)),
|
||||||
DockPlacement::Center,
|
DockPlacement::Center,
|
||||||
window,
|
window,
|
||||||
@@ -150,95 +237,222 @@ impl BrowsePanel {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_rows(&self, range: Range<usize>, cx: &mut Context<Self>) -> Vec<AnyElement> {
|
/// The discovered groups the user is not a member of.
|
||||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
fn discovered_cards(&self, cx: &App) -> Vec<GroupCandidate> {
|
||||||
let panel = cx.entity().downgrade();
|
let member_of: Vec<GroupKey> = GroupsRegistry::global(cx)
|
||||||
|
|
||||||
let registry = GroupsRegistry::global(cx);
|
|
||||||
let joined: Vec<GroupKey> = registry
|
|
||||||
.read(cx)
|
.read(cx)
|
||||||
.groups()
|
.groups()
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter(|group| matches!(group.read(cx).membership(), Membership::Member))
|
||||||
.map(|group| group.read(cx).key().clone())
|
.map(|group| group.read(cx).key().clone())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
self.candidates
|
self.discovery
|
||||||
.get(range)
|
.discovered()
|
||||||
.into_iter()
|
.iter()
|
||||||
.flatten()
|
.filter(|candidate| !member_of.contains(&candidate.key))
|
||||||
.map(|candidate| {
|
.cloned()
|
||||||
let key = candidate.key.clone();
|
.collect()
|
||||||
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 {
|
/// The panel header, item 0 of the list so it scrolls with the cards.
|
||||||
Button::new(format!("browse-joined-{seed}"))
|
fn render_header(&self, cx: &Context<Self>) -> AnyElement {
|
||||||
.label("Joined")
|
h_flex()
|
||||||
.small()
|
.w_full()
|
||||||
.disabled(true)
|
.px_4()
|
||||||
.into_any_element()
|
.h_24()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.justify_between()
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.min_w_0()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.font_semibold()
|
||||||
|
.line_height(relative(1.2))
|
||||||
|
.child("Public Groups"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.truncate()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.map(|this| match &self.relay {
|
||||||
|
Some(relay) => {
|
||||||
|
this.child(SharedString::from(relay.as_str().to_owned()))
|
||||||
|
}
|
||||||
|
None => this.child(SharedString::from(
|
||||||
|
"Hosted on current connected relays",
|
||||||
|
)),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.gap_1()
|
||||||
|
.w_full()
|
||||||
|
.max_w_64()
|
||||||
|
.px_1()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.bg(cx.theme().surface_background)
|
||||||
|
.child(
|
||||||
|
Input::new(&self.input)
|
||||||
|
.appearance(false)
|
||||||
|
.small()
|
||||||
|
.text_sm()
|
||||||
|
.flex_1(),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("relay-browse")
|
||||||
|
.icon(IconName::ArrowRight)
|
||||||
|
.tooltip("Browse by Relay")
|
||||||
|
.ghost()
|
||||||
|
.small()
|
||||||
|
.on_click(cx.listener(|this, _event, window, cx| {
|
||||||
|
this.browse(window, cx);
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_list_rows(&mut self, range: Range<usize>, cx: &mut Context<Self>) -> Vec<AnyElement> {
|
||||||
|
let discover = self.relay.is_none();
|
||||||
|
|
||||||
|
let cards: Vec<GroupCandidate> = if discover {
|
||||||
|
self.discovered_cards(cx)
|
||||||
|
} else {
|
||||||
|
self.candidates.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
range
|
||||||
|
.map(|ix| {
|
||||||
|
if ix == 0 {
|
||||||
|
self.render_header(cx)
|
||||||
} else {
|
} else {
|
||||||
let panel = panel.clone();
|
let pair = cards.chunks(2).nth(ix - 1).unwrap_or(&[]);
|
||||||
let clicked = key.clone();
|
|
||||||
|
|
||||||
Button::new(format!("browse-join-{seed}"))
|
h_flex()
|
||||||
.label("Join")
|
.w_full()
|
||||||
.small()
|
.h(px(CARD_HEIGHT))
|
||||||
.on_click(move |_event, window, cx| {
|
.px_4()
|
||||||
if let Err(error) = panel.update(cx, |panel, cx| {
|
.pb_4()
|
||||||
panel.join(clicked.clone(), window, cx);
|
.gap_4()
|
||||||
}) {
|
.children(pair.first().map(|card| self.render_card(card, cx)))
|
||||||
log::error!("Failed to join a browsed group: {error}");
|
.child(match pair.get(1) {
|
||||||
}
|
Some(card) => self.render_card(card, cx),
|
||||||
|
None => div().flex_1().into_any_element(),
|
||||||
})
|
})
|
||||||
.into_any_element()
|
.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()
|
.collect()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fn notice(text: &'static str, cx: &App) -> AnyElement {
|
fn render_card(&self, candidate: &GroupCandidate, cx: &mut Context<Self>) -> AnyElement {
|
||||||
h_flex()
|
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||||
.size_full()
|
let dock = self.dock.clone();
|
||||||
.justify_center()
|
|
||||||
.text_sm()
|
let key = candidate.key.clone();
|
||||||
.text_color(cx.theme().text_muted)
|
let seed = key.cache_tag();
|
||||||
.child(text)
|
let members = self.discovery.members(&key).unwrap_or(0);
|
||||||
.into_any_element()
|
|
||||||
|
let name = SharedString::from(candidate.display_name().to_owned());
|
||||||
|
let relay = SharedString::from(key.relay().as_str().to_owned());
|
||||||
|
|
||||||
|
let picture = candidate
|
||||||
|
.display_image()
|
||||||
|
.map(|picture| SharedString::from(picture.to_owned()));
|
||||||
|
|
||||||
|
let about = candidate
|
||||||
|
.display_about()
|
||||||
|
.map(|about| SharedString::from(about.to_owned()));
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.id(SharedString::from(format!("card-{seed}")))
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.h_full()
|
||||||
|
.overflow_hidden()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.cursor_pointer()
|
||||||
|
.border_1()
|
||||||
|
.border_color(cx.theme().border_transparent)
|
||||||
|
.bg(cx.theme().surface_background)
|
||||||
|
.hover(|this| {
|
||||||
|
this.bg(cx.theme().elevated_surface_background)
|
||||||
|
.border_color(cx.theme().border_focused)
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.flex_1()
|
||||||
|
.min_h_0()
|
||||||
|
.p_2()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.w_full()
|
||||||
|
.items_center()
|
||||||
|
.gap_2()
|
||||||
|
.when(!hide_avatar, |this| {
|
||||||
|
this.child(Avatar::from_source(picture).seed(seed).flex_shrink_0())
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.truncate()
|
||||||
|
.font_semibold()
|
||||||
|
.line_height(relative(1.2))
|
||||||
|
.child(name),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.truncate()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(cx.theme().text_placeholder)
|
||||||
|
.child(relay),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.min_w_0()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.text_ellipsis()
|
||||||
|
.line_clamp(2)
|
||||||
|
.map(|this| {
|
||||||
|
if let Some(about) = about {
|
||||||
|
this.child(about)
|
||||||
|
} else {
|
||||||
|
this.child(NO_DESCRIPTION)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.child(div().flex_1())
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.w_full()
|
||||||
|
.gap_1p5()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(Icon::new(IconName::Book))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.child(SharedString::from(format!("{members} members"))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.on_click(move |_event, window, cx| {
|
||||||
|
Self::open_panel(&dock, key.clone(), window, cx);
|
||||||
|
})
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Panel for BrowsePanel {
|
impl Panel for BrowsePanel {
|
||||||
@@ -248,7 +462,8 @@ impl Panel for BrowsePanel {
|
|||||||
|
|
||||||
fn title(&self, cx: &App) -> AnyElement {
|
fn title(&self, cx: &App) -> AnyElement {
|
||||||
h_flex()
|
h_flex()
|
||||||
.gap_1p5()
|
.gap_1()
|
||||||
|
.text_xs()
|
||||||
.child(
|
.child(
|
||||||
Icon::new(IconName::Compass)
|
Icon::new(IconName::Compass)
|
||||||
.small()
|
.small()
|
||||||
@@ -269,44 +484,67 @@ impl Focusable for BrowsePanel {
|
|||||||
|
|
||||||
impl Render for BrowsePanel {
|
impl Render for BrowsePanel {
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
let count = self.candidates.len();
|
let view = cx.entity();
|
||||||
|
let discover = self.relay.is_none();
|
||||||
|
|
||||||
let body = if self.relay.is_none() {
|
let cards: Vec<GroupCandidate> = if discover {
|
||||||
notice("Enter a relay to browse its groups.", cx)
|
self.discovered_cards(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 {
|
} else {
|
||||||
notice("No groups found.", cx)
|
self.candidates.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
v_flex()
|
let rows = cards.len().div_ceil(2);
|
||||||
.size_full()
|
|
||||||
.child(
|
let content: AnyElement = if rows > 0 {
|
||||||
h_flex()
|
let mut item_sizes = vec![size(px(0.), px(HEADER_HEIGHT))];
|
||||||
.gap_2()
|
item_sizes.resize(rows + 1, size(px(0.), px(CARD_HEIGHT)));
|
||||||
.p_3()
|
|
||||||
.flex_shrink_0()
|
v_virtual_list(
|
||||||
.child(Input::new(&self.input).cleanable(true).flex_1())
|
view,
|
||||||
.child(
|
"groups",
|
||||||
Button::new("browse-go")
|
Rc::new(item_sizes),
|
||||||
.label("Go")
|
|this, range, _window, cx| this.render_list_rows(range, cx),
|
||||||
.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))
|
.track_scroll(&self.scroll_handle)
|
||||||
|
.h_full()
|
||||||
|
.into_any_element()
|
||||||
|
} else {
|
||||||
|
let text = if discover {
|
||||||
|
if self.discovering {
|
||||||
|
"Discovering groups…"
|
||||||
|
} else {
|
||||||
|
"No groups found."
|
||||||
|
}
|
||||||
|
} else if self.loading {
|
||||||
|
"Loading groups…"
|
||||||
|
} else {
|
||||||
|
"No groups found."
|
||||||
|
};
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.id("browse-content")
|
||||||
|
.size_full()
|
||||||
|
.overflow_y_scroll()
|
||||||
|
.track_scroll(&self.scroll_handle)
|
||||||
|
.child(self.render_header(cx))
|
||||||
|
.child(notice(text, cx))
|
||||||
|
.into_any_element()
|
||||||
|
};
|
||||||
|
|
||||||
|
div()
|
||||||
|
.size_full()
|
||||||
|
.relative()
|
||||||
|
.child(content)
|
||||||
|
.child(Scrollbar::vertical(&self.scroll_handle))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user