Compare commits
5
Commits
be15873e0d
...
b0674774db
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0674774db | ||
|
|
c6dd2bb1ce | ||
|
|
45daeba8a6 | ||
|
|
1a123f6d03 | ||
|
|
86ebf4dc62 |
Generated
+2
@@ -1284,12 +1284,14 @@ version = "1.0.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"concord",
|
||||
"data-encoding",
|
||||
"flume 0.11.1",
|
||||
"futures",
|
||||
"gpui-pre",
|
||||
"log",
|
||||
"nostr-memory",
|
||||
"nostr-sdk",
|
||||
"regex",
|
||||
"serde_json",
|
||||
"smallvec",
|
||||
"smol",
|
||||
|
||||
@@ -10,8 +10,10 @@ state = { path = "../state" }
|
||||
|
||||
gpui.workspace = true
|
||||
nostr-sdk.workspace = true
|
||||
regex = "1"
|
||||
|
||||
anyhow.workspace = true
|
||||
data-encoding.workspace = true
|
||||
flume.workspace = true
|
||||
futures.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
@@ -112,6 +112,10 @@ pub enum CommunityEvent {
|
||||
Unreadable(CommunityId),
|
||||
/// The last round could not reach the community's relays.
|
||||
Failed(CommunityId),
|
||||
Invite {
|
||||
id: CommunityId,
|
||||
url: String,
|
||||
},
|
||||
Error(String),
|
||||
}
|
||||
|
||||
@@ -336,8 +340,7 @@ impl Community {
|
||||
cx.background_spawn(async move {
|
||||
let progress = match receiver.recv_async().await {
|
||||
Ok(progress) => progress,
|
||||
Err(error) => {
|
||||
log::warn!("community: channel round cancelled: {error}");
|
||||
Err(_) => {
|
||||
return Ok(Progress {
|
||||
failed: true,
|
||||
..Progress::default()
|
||||
@@ -377,9 +380,8 @@ impl Community {
|
||||
let finisher = cx.spawn(async move |this, cx| {
|
||||
let result = round.await;
|
||||
|
||||
if let Err(error) = this.update(cx, |this, cx| this.finish_round(channel, result, cx)) {
|
||||
log::warn!("community: a channel round outlived its community: {error}");
|
||||
}
|
||||
this.update(cx, |this, cx| this.finish_round(channel, result, cx))
|
||||
.ok();
|
||||
|
||||
Ok(())
|
||||
});
|
||||
@@ -410,9 +412,7 @@ impl Community {
|
||||
let waiters = std::mem::take(&mut round.waiters);
|
||||
|
||||
for waiter in waiters {
|
||||
if let Err(error) = waiter.try_send(outcome.clone()) {
|
||||
log::warn!("community: a channel round result was not delivered: {error}");
|
||||
}
|
||||
waiter.try_send(outcome.clone()).ok();
|
||||
}
|
||||
|
||||
if let Some(intent) = queued {
|
||||
@@ -620,7 +620,6 @@ impl Community {
|
||||
let (opened, _) = cord03::open(&wrap, &group, &channel, epoch)?;
|
||||
cache::cache_rumor(&client, &channel, &opened).await?;
|
||||
|
||||
sync::connect_relays(&client, &relays).await;
|
||||
sync::publish_wrap(&client, &wrap, &relays).await;
|
||||
|
||||
Ok(opened.rumor_id)
|
||||
@@ -967,14 +966,11 @@ impl Community {
|
||||
};
|
||||
|
||||
self.icon_task = Some(cx.spawn(async move |this, cx| {
|
||||
match sync::resolve_image(&icon, cx).await {
|
||||
Ok(path) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.icon = Some(path);
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
Err(error) => log::warn!("community icon: {error}"),
|
||||
if let Ok(path) = sync::resolve_image(&icon, cx).await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.icon = Some(path);
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
@@ -993,14 +989,11 @@ impl Community {
|
||||
};
|
||||
|
||||
self.banner_task = Some(cx.spawn(async move |this, cx| {
|
||||
match sync::resolve_image(&banner, cx).await {
|
||||
Ok(path) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.banner = Some(path);
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
Err(error) => log::warn!("community banner: {error}"),
|
||||
if let Ok(path) = sync::resolve_image(&banner, cx).await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.banner = Some(path);
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use concord::cord05::{self, CommunityInvite};
|
||||
use nostr_sdk::prelude::*;
|
||||
use regex::Regex;
|
||||
|
||||
pub const KIND_COMMUNITY_ANNOUNCEMENT: u16 = 3314;
|
||||
|
||||
static INVITE_URL: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(
|
||||
r"(?i)(?:https?://\S+?/invite/naddr1[0-9a-z]+#[A-Za-z0-9_-]+)|(?:naddr1[0-9a-z]+#[A-Za-z0-9_-]+)",
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Listing {
|
||||
pub invite_url: String,
|
||||
pub link_signer: PublicKey,
|
||||
pub community: Option<CommunityInvite>,
|
||||
pub active_at: Option<Timestamp>,
|
||||
}
|
||||
|
||||
impl Listing {
|
||||
pub fn from_event(event: &Event) -> Option<Listing> {
|
||||
let invite_url = extract_invite_urls(&event.content).into_iter().next()?;
|
||||
let link_signer = cord05::parse_link(&invite_url).ok()?.link_signer;
|
||||
|
||||
Some(Listing {
|
||||
invite_url,
|
||||
link_signer,
|
||||
community: None,
|
||||
active_at: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_invite_urls(text: &str) -> Vec<String> {
|
||||
let mut urls = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
|
||||
for found in INVITE_URL.find_iter(text) {
|
||||
let candidate = found.as_str();
|
||||
|
||||
let Ok(link) = cord05::parse_link(candidate) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if seen.insert(link.link_signer) {
|
||||
urls.push(candidate.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
urls
|
||||
}
|
||||
|
||||
pub fn build_community_announcement(invite_url: &str) -> Option<EventBuilder> {
|
||||
if cord05::parse_link(invite_url).is_err() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(EventBuilder::new(
|
||||
Kind::Custom(KIND_COMMUNITY_ANNOUNCEMENT),
|
||||
invite_url.trim(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn build_announcement_deletion(ids: &[EventId]) -> EventBuilder {
|
||||
let mut tags: Vec<Tag> = ids.iter().map(|id| Tag::event(*id)).collect();
|
||||
tags.push(Tag::custom("k", [KIND_COMMUNITY_ANNOUNCEMENT.to_string()]));
|
||||
|
||||
EventBuilder::new(Kind::EventDeletion, "").tags(tags)
|
||||
}
|
||||
|
||||
pub fn fold_announcements(announcements: &[Event], deletes: &[Event]) -> Vec<Listing> {
|
||||
let authors: BTreeMap<EventId, PublicKey> = announcements
|
||||
.iter()
|
||||
.filter(|event| event.kind == Kind::Custom(KIND_COMMUNITY_ANNOUNCEMENT))
|
||||
.map(|event| (event.id, event.pubkey))
|
||||
.collect();
|
||||
|
||||
let deleted = deleted_ids(&authors, deletes);
|
||||
|
||||
let mut ordered: Vec<&Event> = announcements.iter().collect();
|
||||
ordered.sort_by_key(|event| std::cmp::Reverse(event.created_at));
|
||||
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut folded = Vec::new();
|
||||
|
||||
for event in ordered {
|
||||
if deleted.contains(&event.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(listing) = Listing::from_event(event) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if seen.insert(listing.link_signer) {
|
||||
folded.push(listing);
|
||||
}
|
||||
}
|
||||
|
||||
folded
|
||||
}
|
||||
|
||||
fn deleted_ids(authors: &BTreeMap<EventId, PublicKey>, deletes: &[Event]) -> BTreeSet<EventId> {
|
||||
let mut deleted = BTreeSet::new();
|
||||
|
||||
for event in deletes
|
||||
.iter()
|
||||
.filter(|event| event.kind == Kind::EventDeletion)
|
||||
{
|
||||
for id in event.tags.event_ids() {
|
||||
if authors.get(&id) == Some(&event.pubkey) {
|
||||
deleted.insert(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deleted
|
||||
}
|
||||
@@ -197,10 +197,7 @@ pub async fn page(
|
||||
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);
|
||||
}
|
||||
None => walk.reject(*index),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,7 +353,7 @@ fn history_subscription() -> SubscriptionId {
|
||||
SubscriptionId::new(format!("history-{}", NEXT.fetch_add(1, Ordering::Relaxed)))
|
||||
}
|
||||
|
||||
async fn within<F>(limit: Duration, future: F) -> Option<F::Output>
|
||||
pub(crate) async fn within<F>(limit: Duration, future: F) -> Option<F::Output>
|
||||
where
|
||||
F: Future,
|
||||
{
|
||||
|
||||
+72
-29
@@ -17,17 +17,16 @@ use crate::rekey::WatchRegistry;
|
||||
|
||||
pub mod cache;
|
||||
mod community;
|
||||
pub mod discover;
|
||||
pub mod history;
|
||||
mod rekey;
|
||||
mod sync;
|
||||
|
||||
pub use community::*;
|
||||
pub use discover::Listing;
|
||||
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) {
|
||||
@@ -142,24 +141,23 @@ pub struct CommunityRegistry {
|
||||
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.
|
||||
/// When a relay last delivered something for a community.
|
||||
last_event: HashMap<CommunityId, Instant>,
|
||||
/// One observer per tracked community, dropped on reset
|
||||
/// 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]>,
|
||||
signal_tx: flume::Sender<Signal>,
|
||||
signal_rx: flume::Receiver<Signal>,
|
||||
/// 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<()>>>,
|
||||
tasks: SmallVec<[Task<Result<()>>; 2]>,
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
|
||||
@@ -173,8 +171,9 @@ impl CommunityRegistry {
|
||||
}
|
||||
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
let entity = cx.entity().downgrade();
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let entity = cx.entity().downgrade();
|
||||
|
||||
let (tx, rx) = flume::bounded::<Signal>(256);
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
@@ -188,15 +187,14 @@ impl CommunityRegistry {
|
||||
}));
|
||||
|
||||
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();
|
||||
let _ = entity.update(cx, |this, cx| {
|
||||
this.handle_notifications(cx);
|
||||
|
||||
if nostr.read(cx).current_user().is_some() {
|
||||
this.subscribe_list(cx);
|
||||
this.load(cx);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Self {
|
||||
@@ -257,6 +255,60 @@ impl CommunityRegistry {
|
||||
}));
|
||||
}
|
||||
|
||||
pub fn invite(&mut self, id: &CommunityId, cx: &mut Context<Self>) {
|
||||
let Some(community) = self.index.get(id).cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let signer = nostr.read(cx).signer();
|
||||
let client = nostr.read(cx).client();
|
||||
|
||||
let state = community.read(cx).state().clone();
|
||||
let id = *id;
|
||||
|
||||
let task =
|
||||
cx.background_spawn(async move { sync::create_invite(&client, &signer, &state).await });
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(url) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(CommunityEvent::Invite { id, url });
|
||||
})?;
|
||||
}
|
||||
Err(error) => {
|
||||
this.update(cx, |_this, cx| {
|
||||
cx.emit(CommunityEvent::Error(error.to_string()));
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
pub fn join(&mut self, url: String, 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 { sync::join(&client, &signer, &url).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;
|
||||
@@ -582,8 +634,6 @@ impl CommunityRegistry {
|
||||
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);
|
||||
})?,
|
||||
@@ -644,18 +694,11 @@ async fn subscribe(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let output = client
|
||||
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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -447,10 +447,6 @@ pub async fn rotate(
|
||||
if let RotationPlan::Base(refounding) = &plan {
|
||||
let carried = carry_heads(client, state, refounding, at).await?;
|
||||
|
||||
if carried.is_empty() && !state.heads.is_empty() {
|
||||
log::warn!("community: a refounding carried no settled head forward");
|
||||
}
|
||||
|
||||
wraps.extend(carried);
|
||||
}
|
||||
|
||||
|
||||
+507
-31
@@ -1,5 +1,7 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use concord::cord01::KIND_WRAP_EPHEMERAL;
|
||||
@@ -7,20 +9,27 @@ use concord::cord02::list::{CommunityList, JoinMaterial, KIND_COMMUNITY_LIST};
|
||||
use concord::cord02::{self, ControlFold, ImageRef};
|
||||
use concord::cord04::AuthorityCitation;
|
||||
use concord::cord04::roles::{Permissions, citation_ok};
|
||||
use concord::cord05::{
|
||||
self, BundleState, CommunityInvite, InviteEntry, InviteList, ParsedInviteLink,
|
||||
};
|
||||
use concord::derive::{
|
||||
channel_group_key, control_group_key, control_signer_group_key, guestbook_group_key,
|
||||
invite_bundle_key,
|
||||
};
|
||||
use concord::state::{CommunityState, HeldKey, HeldRoot, list_entry};
|
||||
use concord::{ChannelId, CommunityId, Epoch, GroupKey, decode_hex_32};
|
||||
use data_encoding::HEXLOWER;
|
||||
use gpui::AsyncApp;
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::UniversalSigner;
|
||||
use state::{BOOTSTRAP_RELAYS, UniversalSigner};
|
||||
|
||||
use crate::cache::{self, Observed};
|
||||
use crate::history::{CURSOR_OVERLAP, Window};
|
||||
use crate::discover::{self, Listing};
|
||||
use crate::history::{CURSOR_OVERLAP, Window, within};
|
||||
|
||||
/// How much of what a relay stores a cold subscription replays per relay.
|
||||
const LIVE_REPLAY: usize = 500;
|
||||
const INVITE_BASE: &str = "https://coopchat.xyz";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum PlaneKind {
|
||||
@@ -213,10 +222,497 @@ where
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
/// Best-effort publication of the genesis wraps to the community's relays.
|
||||
pub(crate) async fn publish_wraps(client: &Client, wraps: &[Event], relays: &[RelayUrl]) {
|
||||
pub async fn create_invite<S>(client: &Client, signer: &S, state: &CommunityState) -> Result<String>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let link_signer = Keys::generate();
|
||||
let token = cord05::mint_token()?;
|
||||
|
||||
let bundle_key = invite_bundle_key(&token);
|
||||
let relays: Vec<String> = state.relays.iter().map(|relay| relay.to_string()).collect();
|
||||
|
||||
let invite = CommunityInvite {
|
||||
community_id: state.id,
|
||||
owner: state.owner,
|
||||
owner_salt: HEXLOWER.encode(&state.owner_salt),
|
||||
community_root: HEXLOWER.encode(&state.community_root),
|
||||
root_epoch: state.root_epoch,
|
||||
control_pk: state.control_pks.get(&state.root_epoch.0).copied(),
|
||||
channels: Vec::new(),
|
||||
relays: relays.clone(),
|
||||
name: state.name.clone().unwrap_or_default(),
|
||||
icon: None,
|
||||
expires_at: None,
|
||||
creator_npub: None,
|
||||
label: None,
|
||||
extra: Default::default(),
|
||||
};
|
||||
|
||||
let bundle = cord05::build_bundle_event(&link_signer, &invite, &bundle_key)?;
|
||||
|
||||
publish_wrap(client, &bundle, &state.relays).await;
|
||||
|
||||
let url = cord05::build_invite_url(INVITE_BASE, &link_signer.public_key(), &token, &relays)?;
|
||||
|
||||
let entry = InviteEntry {
|
||||
token: HEXLOWER.encode(&token),
|
||||
signer_sk: link_signer.secret_key().to_secret_hex(),
|
||||
community_id: state.id,
|
||||
url: url.clone(),
|
||||
label: None,
|
||||
created_at: Timestamp::now().as_secs(),
|
||||
expires_at: None,
|
||||
extra: Default::default(),
|
||||
};
|
||||
|
||||
record_invite(client, signer, entry).await?;
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
pub async fn join<S>(client: &Client, signer: &S, url: &str) -> Result<CommunityState>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let link = cord05::parse_link(url)?;
|
||||
let invite = resolve_bundle(client, &link, &[]).await?;
|
||||
|
||||
let material = cord02::list::join_material(&invite, None);
|
||||
let self_pk = signer.get_public_key_async().await?;
|
||||
let at_ms = Timestamp::now().as_secs().saturating_mul(1000);
|
||||
let state = CommunityState::from_join_material(&material, at_ms)?;
|
||||
|
||||
cache::save_state(client, &state).await?;
|
||||
|
||||
let group = guestbook_group_key(&state.community_root, &state.id, state.root_epoch)?;
|
||||
let rumor = cord02::guestbook::build_join(self_pk, None, at_ms);
|
||||
let (wrap, _) = cord02::guestbook::seal_rumor(&rumor, &group, signer).await?;
|
||||
|
||||
publish_wrap(client, &wrap, &state.relays).await;
|
||||
|
||||
if let Err(error) = record_membership(client, signer, &state, &invite.name).await {
|
||||
log::warn!(
|
||||
"community {}: recording the membership failed: {error}",
|
||||
state.id.to_hex()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_bundle(
|
||||
client: &Client,
|
||||
link: &ParsedInviteLink,
|
||||
fallback: &[RelayUrl],
|
||||
) -> Result<CommunityInvite> {
|
||||
let mut relays: Vec<RelayUrl> = link
|
||||
.bootstrap_relays
|
||||
.iter()
|
||||
.filter_map(|relay| RelayUrl::parse(relay).ok())
|
||||
.collect();
|
||||
|
||||
if relays.is_empty() {
|
||||
relays = fallback.to_vec();
|
||||
}
|
||||
|
||||
if relays.is_empty() {
|
||||
anyhow::bail!("invite link names no relays");
|
||||
}
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(cord05::KIND_BUNDLE))
|
||||
.author(link.link_signer)
|
||||
.identifier("");
|
||||
|
||||
let events = read_events(client, &relays, std::slice::from_ref(&filter)).await?;
|
||||
|
||||
let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else {
|
||||
anyhow::bail!("invite bundle not found on its relays");
|
||||
};
|
||||
|
||||
let bundle_key = invite_bundle_key(&link.token);
|
||||
|
||||
match cord05::parse_bundle_event(&event, &link.link_signer, &bundle_key)? {
|
||||
BundleState::Live(invite) => Ok(*invite),
|
||||
BundleState::Revoked => anyhow::bail!("invite link has been revoked"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn record_invite<S>(client: &Client, signer: &S, entry: InviteEntry) -> Result<()>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let self_pk = signer.get_public_key_async().await?;
|
||||
let held = load_invite_list(client, signer, self_pk)
|
||||
.await?
|
||||
.unwrap_or_default();
|
||||
|
||||
let incoming = InviteList {
|
||||
entries: vec![entry],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let list = cord05::merge_invite_lists(held, incoming);
|
||||
let event = cord05::build_invite_list(signer, &list).await?;
|
||||
|
||||
publish_list(client, &event).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_invite_list<S>(
|
||||
client: &Client,
|
||||
signer: &S,
|
||||
self_pk: PublicKey,
|
||||
) -> Result<Option<InviteList>>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(cord05::KIND_INVITE_LIST))
|
||||
.author(self_pk);
|
||||
|
||||
let mut merged: Option<InviteList> = None;
|
||||
|
||||
for event in client.database().query(filter).await? {
|
||||
match cord05::parse_invite_list(signer, &event).await {
|
||||
Ok(list) => {
|
||||
merged = Some(match merged {
|
||||
Some(held) => cord05::merge_invite_lists(held, list),
|
||||
None => list,
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!("ignoring unreadable invite list {}: {error}", event.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
const DISCOVER_LIMIT: usize = 100;
|
||||
const ACTIVITY_FILTERS_PER_REQ: usize = 20;
|
||||
|
||||
pub(crate) struct ActivityTarget {
|
||||
pub link_signer: PublicKey,
|
||||
pub authors: Vec<PublicKey>,
|
||||
pub relays: Vec<RelayUrl>,
|
||||
}
|
||||
|
||||
pub(crate) async fn discover_relays(client: &Client, self_pk: Option<PublicKey>) -> Vec<RelayUrl> {
|
||||
let mut relays: BTreeSet<RelayUrl> = BOOTSTRAP_RELAYS
|
||||
.into_iter()
|
||||
.filter_map(|url| RelayUrl::parse(url).ok())
|
||||
.collect();
|
||||
|
||||
if let Some(self_pk) = self_pk {
|
||||
let filter = Filter::new().kind(Kind::RelayList).author(self_pk).limit(1);
|
||||
|
||||
if let Ok(events) = client.database().query(filter).await
|
||||
&& let Some(event) = events.into_iter().max_by_key(|event| event.created_at)
|
||||
{
|
||||
for (url, metadata) in nip65::extract_relay_list(&event) {
|
||||
if metadata != Some(RelayMetadata::Write) {
|
||||
relays.insert(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
relays.into_iter().collect()
|
||||
}
|
||||
|
||||
pub async fn discover_page(client: &Client, self_pk: Option<PublicKey>) -> Result<Vec<Listing>> {
|
||||
let relays = discover_relays(client, self_pk).await;
|
||||
|
||||
if relays.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let (announcements, deletes) = read_announcements(client, &relays).await?;
|
||||
let invites = discover::fold_announcements(&announcements, &deletes);
|
||||
|
||||
let mut listings = futures::future::join_all(invites.into_iter().map(|mut listing| {
|
||||
let fallback = relays.clone();
|
||||
|
||||
async move {
|
||||
listing.community = match cord05::parse_link(&listing.invite_url) {
|
||||
Ok(link) => resolve_bundle(client, &link, &fallback).await.ok(),
|
||||
Err(_) => None,
|
||||
};
|
||||
|
||||
listing
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
|
||||
let targets: Vec<ActivityTarget> = listings.iter().filter_map(activity_target).collect();
|
||||
|
||||
if !targets.is_empty() {
|
||||
for (link_signer, at) in probe_activity(client, &targets).await {
|
||||
if let Some(listing) = listings
|
||||
.iter_mut()
|
||||
.find(|listing| listing.link_signer == link_signer)
|
||||
{
|
||||
listing.active_at = Some(at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(listings)
|
||||
}
|
||||
|
||||
fn activity_target(listing: &Listing) -> Option<ActivityTarget> {
|
||||
let community = listing.community.as_ref()?;
|
||||
let authors = activity_authors(community);
|
||||
|
||||
if authors.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(ActivityTarget {
|
||||
link_signer: listing.link_signer,
|
||||
authors,
|
||||
relays: community
|
||||
.relays
|
||||
.iter()
|
||||
.filter_map(|relay| RelayUrl::parse(relay).ok())
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
fn activity_authors(invite: &CommunityInvite) -> Vec<PublicKey> {
|
||||
let Ok(root) = decode_hex_32(&invite.community_root) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut authors = Vec::new();
|
||||
|
||||
if let Ok(group) = guestbook_group_key(&root, &invite.community_id, invite.root_epoch) {
|
||||
authors.push(group.pk());
|
||||
}
|
||||
|
||||
match invite.control_pk {
|
||||
Some(control_pk) => authors.push(control_pk),
|
||||
None => {
|
||||
if let Ok(group) = control_group_key(&root, &invite.community_id, invite.root_epoch) {
|
||||
authors.push(group.pk());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for channel in &invite.channels {
|
||||
if let Some(key) = &channel.key
|
||||
&& let Ok(key) = decode_hex_32(key)
|
||||
&& let Ok(group) = channel_group_key(&key, &channel.id, channel.epoch)
|
||||
{
|
||||
authors.push(group.pk());
|
||||
}
|
||||
}
|
||||
|
||||
authors.sort();
|
||||
authors.dedup();
|
||||
authors
|
||||
}
|
||||
|
||||
async fn read_announcements(
|
||||
client: &Client,
|
||||
relays: &[RelayUrl],
|
||||
) -> Result<(Vec<Event>, Vec<Event>)> {
|
||||
let filters = vec![
|
||||
Filter::new()
|
||||
.kind(Kind::Custom(discover::KIND_COMMUNITY_ANNOUNCEMENT))
|
||||
.limit(DISCOVER_LIMIT),
|
||||
Filter::new()
|
||||
.kind(Kind::EventDeletion)
|
||||
.custom_tag(
|
||||
SingleLetterTag::LOWERCASE_K,
|
||||
discover::KIND_COMMUNITY_ANNOUNCEMENT.to_string(),
|
||||
)
|
||||
.limit(DISCOVER_LIMIT),
|
||||
];
|
||||
|
||||
let events = read_events(client, relays, &filters).await?;
|
||||
|
||||
let mut found = Vec::new();
|
||||
let mut deleted = Vec::new();
|
||||
|
||||
for event in events {
|
||||
if event.kind == Kind::Custom(discover::KIND_COMMUNITY_ANNOUNCEMENT) {
|
||||
found.push(event);
|
||||
} else if event.kind == Kind::EventDeletion {
|
||||
deleted.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
Ok((found, deleted))
|
||||
}
|
||||
|
||||
async fn probe_activity(
|
||||
client: &Client,
|
||||
targets: &[ActivityTarget],
|
||||
) -> Vec<(PublicKey, Timestamp)> {
|
||||
let now = Timestamp::now();
|
||||
|
||||
let mut by_author: BTreeMap<PublicKey, BTreeSet<PublicKey>> = BTreeMap::new();
|
||||
let mut groups: BTreeMap<Vec<RelayUrl>, Vec<&ActivityTarget>> = BTreeMap::new();
|
||||
|
||||
for target in targets {
|
||||
if target.relays.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
for author in &target.authors {
|
||||
by_author
|
||||
.entry(*author)
|
||||
.or_default()
|
||||
.insert(target.link_signer);
|
||||
}
|
||||
|
||||
groups
|
||||
.entry(target.relays.clone())
|
||||
.or_default()
|
||||
.push(target);
|
||||
}
|
||||
|
||||
let mut newest: BTreeMap<PublicKey, Timestamp> = BTreeMap::new();
|
||||
|
||||
for (relays, group) in groups {
|
||||
for chunk in group.chunks(ACTIVITY_FILTERS_PER_REQ) {
|
||||
let filters: Vec<Filter> = chunk
|
||||
.iter()
|
||||
.map(|target| {
|
||||
Filter::new()
|
||||
.kind(Kind::GiftWrap)
|
||||
.authors(target.authors.iter().copied())
|
||||
.until(now)
|
||||
.limit(1)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let events = match read_events(client, &relays, &filters).await {
|
||||
Ok(events) => events,
|
||||
Err(error) => {
|
||||
log::warn!("discover: activity probe failed: {error}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for event in events {
|
||||
if event.created_at > now {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(signers) = by_author.get(&event.pubkey) {
|
||||
for signer in signers {
|
||||
let entry = newest.entry(*signer).or_insert(event.created_at);
|
||||
|
||||
if event.created_at > *entry {
|
||||
*entry = event.created_at;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newest.into_iter().collect()
|
||||
}
|
||||
|
||||
async fn pool_targets(
|
||||
client: &Client,
|
||||
relays: &[RelayUrl],
|
||||
filters: &[Filter],
|
||||
) -> Vec<(RelayUrl, Vec<Filter>)> {
|
||||
let mut targets = Vec::with_capacity(relays.len());
|
||||
|
||||
for url in relays {
|
||||
if let Ok(Some(_)) = client.relay(url).await {
|
||||
targets.push((url.clone(), filters.to_vec()));
|
||||
}
|
||||
}
|
||||
|
||||
targets
|
||||
}
|
||||
|
||||
const READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
fn read_subscription() -> SubscriptionId {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
SubscriptionId::new(format!("read-{}", NEXT.fetch_add(1, Ordering::Relaxed)))
|
||||
}
|
||||
|
||||
fn settled_subscription<'m>(message: &'m RelayMessage<'_>) -> Option<&'m SubscriptionId> {
|
||||
match message {
|
||||
RelayMessage::EndOfStoredEvents(id) => Some(id),
|
||||
RelayMessage::Closed {
|
||||
subscription_id, ..
|
||||
} => Some(subscription_id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_events(
|
||||
client: &Client,
|
||||
relays: &[RelayUrl],
|
||||
filters: &[Filter],
|
||||
) -> Result<Vec<Event>> {
|
||||
connect_relays(client, relays).await;
|
||||
|
||||
let targets = pool_targets(client, relays, filters).await;
|
||||
|
||||
if targets.is_empty() {
|
||||
anyhow::bail!("no relay is reachable");
|
||||
}
|
||||
|
||||
let expected: BTreeSet<RelayUrl> = targets.iter().map(|(url, _)| url.clone()).collect();
|
||||
let id = read_subscription();
|
||||
let mut notifications = client.notifications();
|
||||
|
||||
let options = SubscribeAutoCloseOptions::default()
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||
.timeout(Some(READ_TIMEOUT));
|
||||
|
||||
let output = client
|
||||
.subscribe(ReqTarget::manual(targets))
|
||||
.with_id(id.clone())
|
||||
.close_on(options)
|
||||
.await?;
|
||||
|
||||
let deadline = Instant::now() + READ_TIMEOUT;
|
||||
let mut settled: BTreeSet<RelayUrl> = output.failed.into_keys().collect();
|
||||
|
||||
while settled.len() < expected.len() {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
|
||||
let Some(notification) = within(remaining, notifications.next()).await.flatten() else {
|
||||
break;
|
||||
};
|
||||
|
||||
match notification {
|
||||
ClientNotification::Message { relay_url, message }
|
||||
if settled_subscription(&message) == Some(&id) =>
|
||||
{
|
||||
settled.insert(relay_url);
|
||||
}
|
||||
ClientNotification::Shutdown => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut events = BTreeSet::new();
|
||||
|
||||
for filter in filters {
|
||||
events.extend(client.database().query(filter.clone()).await?);
|
||||
}
|
||||
|
||||
Ok(events.into_iter().collect())
|
||||
}
|
||||
|
||||
/// Best-effort publication of the genesis wraps to the community's relays.
|
||||
pub(crate) async fn publish_wraps(client: &Client, wraps: &[Event], relays: &[RelayUrl]) {
|
||||
for wrap in wraps {
|
||||
publish_wrap(client, wrap, relays).await;
|
||||
}
|
||||
@@ -233,20 +729,16 @@ pub(crate) async fn connect_relays(client: &Client, relays: &[RelayUrl]) {
|
||||
|
||||
/// Best-effort publication of a single wrap to the community's relays.
|
||||
pub(crate) async fn publish_wrap(client: &Client, wrap: &Event, relays: &[RelayUrl]) {
|
||||
connect_relays(client, relays).await;
|
||||
|
||||
let sent = if relays.is_empty() {
|
||||
client.send_event(wrap).broadcast().await
|
||||
} else {
|
||||
client.send_event(wrap).to(relays.iter().cloned()).await
|
||||
};
|
||||
|
||||
match sent {
|
||||
Ok(output) if output.failed.is_empty() => {}
|
||||
Ok(output) => log::warn!(
|
||||
"community: {} relay(s) rejected {}",
|
||||
output.failed.len(),
|
||||
wrap.id
|
||||
),
|
||||
Err(error) => log::warn!("community: publishing {} failed: {error}", wrap.id),
|
||||
if let Err(error) = sent {
|
||||
log::warn!("community: publishing {} failed: {error}", wrap.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,10 +757,6 @@ where
|
||||
let frags = held.as_ref().map_or(1, |list| list.frags);
|
||||
|
||||
if frags > 1 {
|
||||
log::warn!(
|
||||
"community {}: the list spans {frags} fragments; deferring the membership write",
|
||||
state.id.to_hex()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -289,13 +777,8 @@ where
|
||||
}
|
||||
|
||||
async fn publish_list(client: &Client, event: &Event) {
|
||||
match client.send_event(event).to_nip65().await {
|
||||
Ok(output) if output.failed.is_empty() => {}
|
||||
Ok(output) => log::warn!(
|
||||
"community list: {} relay(s) rejected the publish",
|
||||
output.failed.len()
|
||||
),
|
||||
Err(error) => log::warn!("community list: publish failed: {error}"),
|
||||
if let Err(error) = client.send_event(event).to_nip65().await {
|
||||
log::warn!("community list: publish failed: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,18 +811,11 @@ pub async fn subscribe_list(client: &Client, self_pk: PublicKey) -> Result<()> {
|
||||
.kind(Kind::Custom(KIND_COMMUNITY_LIST))
|
||||
.author(self_pk);
|
||||
|
||||
let output = client
|
||||
client
|
||||
.subscribe(ReqTarget::auto(vec![filter]))
|
||||
.with_id(id)
|
||||
.await?;
|
||||
|
||||
if !output.failed.is_empty() {
|
||||
log::warn!(
|
||||
"community list: {} relay(s) rejected the subscription",
|
||||
output.failed.len()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -3,14 +3,14 @@ use std::fmt;
|
||||
|
||||
use anyhow::Result;
|
||||
use community::{
|
||||
ChannelId, ChatMessage, Community, CommunityEvent, Epoch, Intent, LOAD_OLDER_PAGES,
|
||||
TIMELINE_PAGE, Timeline,
|
||||
ChannelId, ChatMessage, Community, CommunityEvent, CommunityRegistry, 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,
|
||||
AnyElement, App, AppContext, ClipboardItem, 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};
|
||||
@@ -112,11 +112,13 @@ pub struct CommunityPanel {
|
||||
/// Spawned reads and publishes, cancelled when the panel closes
|
||||
tasks: SmallVec<[Task<Result<()>>; 4]>,
|
||||
/// Event subscriptions
|
||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
||||
_subscriptions: SmallVec<[Subscription; 3]>,
|
||||
}
|
||||
|
||||
impl CommunityPanel {
|
||||
pub fn new(community: Entity<Community>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let store = CommunityRegistry::global(cx);
|
||||
|
||||
let (id, name, channel) = {
|
||||
let community = community.read(cx);
|
||||
|
||||
@@ -166,6 +168,21 @@ impl CommunityPanel {
|
||||
},
|
||||
));
|
||||
|
||||
subscriptions.push(
|
||||
cx.subscribe_in(&store, window, |this, _, event, window, cx| {
|
||||
if let CommunityEvent::Invite { id, url } = event {
|
||||
let ours = this
|
||||
.community
|
||||
.read_with(cx, |community, _cx| community.id())
|
||||
.ok();
|
||||
if ours == Some(*id) {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(url.clone()));
|
||||
window.push_notification("Invite link copied", cx);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.list_state.set_follow_mode(FollowMode::Tail);
|
||||
this.list_state.set_scroll_handler(cx.listener(
|
||||
@@ -772,7 +789,24 @@ impl Panel for CommunityPanel {
|
||||
}
|
||||
|
||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||
vec![]
|
||||
let community = self.community.clone();
|
||||
|
||||
vec![
|
||||
Button::new("invite")
|
||||
.icon(IconName::Link)
|
||||
.tooltip("Copy invite link")
|
||||
.ghost()
|
||||
.on_click(move |_event, _window, cx: &mut App| {
|
||||
let Some(community) = community.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let state = CommunityRegistry::global(cx);
|
||||
let id = community.read(cx).id();
|
||||
|
||||
state.update(cx, |this, cx| this.invite(&id, cx));
|
||||
}),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,6 @@ pub struct CommunityInvite {
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<ImageRef>,
|
||||
/// Unix **ms**: past it the preview still renders, joining refuses.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -396,6 +395,12 @@ pub fn bundle_naddr(link_signer: &PublicKey) -> Result<String, InviteError> {
|
||||
.map_err(|_| InviteError::BadLink("invalid naddr"))
|
||||
}
|
||||
|
||||
pub fn mint_token() -> Result<[u8; TOKEN_LEN], InviteError> {
|
||||
let mut token = [0u8; TOKEN_LEN];
|
||||
crate::fill_random(&mut token).map_err(crypto_error)?;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub fn build_invite_url(
|
||||
base: &str,
|
||||
link_signer: &PublicKey,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
use community::CommunityRegistry;
|
||||
use gpui::{App, AppContext, ParentElement, Styled, Window, px};
|
||||
use ui::WindowExtension;
|
||||
use ui::input::{Input, InputState};
|
||||
|
||||
pub fn open(window: &mut Window, cx: &mut App) {
|
||||
let link_input = cx.new(|cx| InputState::new(window, cx).placeholder("Invite link"));
|
||||
|
||||
window.open_dialog(cx, move |this, _window, _cx| {
|
||||
let link_input = link_input.clone();
|
||||
|
||||
this.width(px(420.))
|
||||
.confirm()
|
||||
.title("Join community")
|
||||
.child(Input::new(&link_input).text_sm())
|
||||
.on_ok(move |_event, _window, cx| {
|
||||
let community = CommunityRegistry::global(cx);
|
||||
let link = link_input.read(cx).value().trim().to_owned();
|
||||
|
||||
if link.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
community.update(cx, |this, cx| {
|
||||
this.join(link, cx);
|
||||
});
|
||||
|
||||
true
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod import;
|
||||
pub mod join_community;
|
||||
pub mod new_chat;
|
||||
pub mod new_community;
|
||||
pub mod restore;
|
||||
|
||||
@@ -21,7 +21,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_community, new_chat, new_community, settings};
|
||||
use crate::panels::{
|
||||
backup, browse, contact_list, greeter, inbox, messaging_relays, profile, relay_list, requests,
|
||||
search,
|
||||
@@ -61,6 +61,7 @@ enum Command {
|
||||
ShowSearch,
|
||||
NewChat,
|
||||
NewCommunity,
|
||||
JoinCommunity,
|
||||
}
|
||||
|
||||
pub struct Workspace {
|
||||
@@ -294,6 +295,9 @@ impl Workspace {
|
||||
Command::NewCommunity => {
|
||||
new_community::open(window, cx);
|
||||
}
|
||||
Command::JoinCommunity => {
|
||||
join_community::open(window, cx);
|
||||
}
|
||||
Command::ShowBackup => {
|
||||
self.add_panel_to_dock(backup::init(window, cx), DockPlacement::Left, window, cx);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
use std::collections::HashMap;
|
||||
use std::ops::Range;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use common::TimestampExt;
|
||||
use community::{CommunityRegistry, Listing};
|
||||
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, Task, Window, div,
|
||||
uniform_list,
|
||||
};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::NostrRegistry;
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
use ui::dock::{Panel, PanelEvent};
|
||||
use ui::{Icon, IconName, Sizable, h_flex};
|
||||
use ui::input::{Input, InputEvent, InputState};
|
||||
use ui::skeleton::Skeleton;
|
||||
use ui::{Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<BrowsePanel> {
|
||||
cx.new(|cx| BrowsePanel::new(window, cx))
|
||||
@@ -13,15 +29,229 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<BrowsePanel> {
|
||||
pub struct BrowsePanel {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
search: Entity<InputState>,
|
||||
listings: Option<Vec<Listing>>,
|
||||
icons: HashMap<PublicKey, PathBuf>,
|
||||
loading: bool,
|
||||
error: Option<SharedString>,
|
||||
tasks: SmallVec<[Task<Result<()>>; 2]>,
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl BrowsePanel {
|
||||
fn new(_window: &mut Window, cx: &mut App) -> Self {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search communities"));
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&search,
|
||||
window,
|
||||
|_this, _state, event, _window, cx| {
|
||||
if let InputEvent::Change = event {
|
||||
cx.notify();
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
cx.defer_in(window, |this, _window, cx| this.load(cx));
|
||||
|
||||
Self {
|
||||
name: "Browse".into(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
search,
|
||||
listings: None,
|
||||
icons: HashMap::new(),
|
||||
loading: false,
|
||||
error: None,
|
||||
tasks: smallvec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
fn load(&mut self, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let self_pk = nostr.read(cx).current_user();
|
||||
|
||||
self.loading = true;
|
||||
self.error = None;
|
||||
self.icons.clear();
|
||||
cx.notify();
|
||||
|
||||
let task =
|
||||
cx.background_spawn(async move { community::discover_page(&client, self_pk).await });
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
Ok(listings) => this.update(cx, |this, cx| {
|
||||
this.loading = false;
|
||||
this.listings = Some(listings);
|
||||
this.load_icons(cx);
|
||||
cx.notify();
|
||||
})?,
|
||||
Err(error) => this.update(cx, |this, cx| {
|
||||
this.loading = false;
|
||||
this.error = Some(error.to_string().into());
|
||||
cx.notify();
|
||||
})?,
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
fn load_icons(&mut self, cx: &mut Context<Self>) {
|
||||
let icons: Vec<_> = self
|
||||
.listings
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter_map(|listing| {
|
||||
let icon = listing.community.as_ref()?.icon.clone()?;
|
||||
Some((listing.link_signer, icon))
|
||||
})
|
||||
.collect();
|
||||
|
||||
for (signer, icon) in icons {
|
||||
if self.icons.contains_key(&signer) {
|
||||
continue;
|
||||
}
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if let Ok(path) = community::resolve_image(&icon, cx).await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.icons.insert(signer, path);
|
||||
cx.notify();
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
fn visible<'a>(&'a self, cx: &App) -> Vec<&'a Listing> {
|
||||
let query = self.search.read(cx).value().trim().to_lowercase();
|
||||
|
||||
self.listings
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|listing| {
|
||||
query.is_empty() || card_name(listing).to_lowercase().contains(&query)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render_loading(&self, cx: &App) -> impl IntoElement {
|
||||
v_flex().gap_2().children((0..6).map(|_| {
|
||||
h_flex()
|
||||
.gap_3()
|
||||
.p_3()
|
||||
.rounded_lg()
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.child(Skeleton::new().w_10().h_10().rounded_full().flex_shrink_0())
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.child(Skeleton::new().w_40().h_4())
|
||||
.child(Skeleton::new().w_24().h_3()),
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
fn render_cards(&self, range: Range<usize>, cx: &Context<Self>) -> Vec<AnyElement> {
|
||||
self.visible(cx)
|
||||
.get(range)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.copied()
|
||||
.map(|listing| render_card(listing, self.icons.get(&listing.link_signer), cx))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn card_name(listing: &Listing) -> SharedString {
|
||||
match listing.community.as_ref() {
|
||||
Some(community) => community.name.clone().into(),
|
||||
None => "Encrypted community".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_card(listing: &Listing, icon: Option<&PathBuf>, cx: &App) -> AnyElement {
|
||||
let mut details: Vec<SharedString> = Vec::new();
|
||||
|
||||
if let Some(community) = listing.community.as_ref()
|
||||
&& !community.channels.is_empty()
|
||||
{
|
||||
details.push(format!("{} channels", community.channels.len()).into());
|
||||
}
|
||||
|
||||
if let Some(active_at) = listing.active_at {
|
||||
details.push(format!("Active {}", active_at.to_ago()).into());
|
||||
}
|
||||
|
||||
let invite_url = listing.invite_url.clone();
|
||||
|
||||
h_flex()
|
||||
.gap_3()
|
||||
.p_3()
|
||||
.rounded_lg()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border)
|
||||
.bg(cx.theme().elevated_surface_background)
|
||||
.child(
|
||||
Avatar::from_source(icon.cloned())
|
||||
.seed(listing.link_signer.to_hex())
|
||||
.small(),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.font_medium()
|
||||
.truncate()
|
||||
.child(card_name(listing)),
|
||||
)
|
||||
.when(!details.is_empty(), |this| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.truncate()
|
||||
.child(details.join(" · ")),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new(format!("join-{}", listing.link_signer.to_hex()))
|
||||
.label("Join")
|
||||
.primary()
|
||||
.on_click(move |_event, _window, cx: &mut App| {
|
||||
let registry = CommunityRegistry::global(cx);
|
||||
registry.update(cx, |registry, cx| registry.join(invite_url.clone(), cx));
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_message(icon: IconName, message: SharedString, cx: &App) -> impl IntoElement {
|
||||
v_flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.child(Icon::new(icon).large().text_color(cx.theme().icon_muted))
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(message),
|
||||
)
|
||||
}
|
||||
|
||||
impl Panel for BrowsePanel {
|
||||
@@ -52,11 +282,51 @@ impl Focusable for BrowsePanel {
|
||||
|
||||
impl Render for BrowsePanel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
h_flex()
|
||||
let header = h_flex()
|
||||
.gap_2()
|
||||
.child(div().flex_1().child(Input::new(&self.search).small()))
|
||||
.child(
|
||||
Button::new("refresh")
|
||||
.icon(IconName::Refresh)
|
||||
.tooltip("Refresh")
|
||||
.ghost()
|
||||
.on_click(cx.listener(|this, _event, _window, cx| this.load(cx))),
|
||||
);
|
||||
|
||||
let body = if self.loading && self.listings.is_none() {
|
||||
self.render_loading(cx).into_any_element()
|
||||
} else if let Some(error) = self.error.clone()
|
||||
&& self.listings.is_none()
|
||||
{
|
||||
render_message(IconName::Warning, error, cx).into_any_element()
|
||||
} else {
|
||||
let visible = self.visible(cx).len();
|
||||
let empty = self.listings.as_ref().is_none_or(Vec::is_empty);
|
||||
|
||||
if visible == 0 {
|
||||
let message = if empty {
|
||||
"No communities are listed yet".into()
|
||||
} else {
|
||||
"No community matches your search".into()
|
||||
};
|
||||
|
||||
render_message(IconName::Compass, message, cx).into_any_element()
|
||||
} else {
|
||||
uniform_list(
|
||||
"browse-listings",
|
||||
visible,
|
||||
cx.processor(|this, range, _window, cx| this.render_cards(range, cx)),
|
||||
)
|
||||
.flex_1()
|
||||
.into_any_element()
|
||||
}
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.justify_center()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(self.name.clone())
|
||||
.gap_3()
|
||||
.p_3()
|
||||
.child(header)
|
||||
.child(div().flex_1().min_h_0().child(body))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -586,7 +586,7 @@ impl Sidebar {
|
||||
.gap_1()
|
||||
.child(
|
||||
NavItem::new(
|
||||
"new-community",
|
||||
"new",
|
||||
"New Community",
|
||||
Icon::new(IconName::Group).small(),
|
||||
)
|
||||
@@ -594,6 +594,16 @@ impl Sidebar {
|
||||
window.dispatch_action(Box::new(Command::NewCommunity), cx)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
NavItem::new(
|
||||
"join",
|
||||
"Join Community",
|
||||
Icon::new(IconName::Invite).small(),
|
||||
)
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(Box::new(Command::JoinCommunity), cx)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
NavItem::new("browse", "Browse", Icon::new(IconName::Compass).small())
|
||||
.on_click(|_, window, cx| {
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
# Discovering public communities
|
||||
|
||||
How to add a Discover/Browse feed of public communities, modelled on Armada's
|
||||
Discover but **unfiltered by default** — every announced community shows, with no
|
||||
curation allow-list. This document is a plan, not a description of shipped code;
|
||||
nothing below is wired up yet.
|
||||
|
||||
## Goal
|
||||
|
||||
Let a user browse communities they are not already in, see their name, icon,
|
||||
member count and last-active time, and join one in a click — without an invite
|
||||
being sent to them directly. Joining stays the existing Concord join path; the new
|
||||
part is the public listing and its discovery.
|
||||
|
||||
## What "all communities" can and cannot mean
|
||||
|
||||
Concord communities are encrypted and have no global public registry. A community
|
||||
exists on the wire only for holders of its `community_root` (see
|
||||
`concord-usage.md`). So a feed can only ever enumerate communities whose owner
|
||||
**explicitly and publicly shared a joinable invite link**. "Show all by default"
|
||||
therefore means: among announced communities, apply no author allow-list — not
|
||||
"enumerate every Concord community".
|
||||
|
||||
Armada gates its feeds on an author allow-list: a kind-39089 follow pack (the
|
||||
Armada team pack by default) plus the viewer and their follows, with
|
||||
`curation: "none"` or `discoverAllContent` removing the filter. We take the
|
||||
filterless branch as the **default** and keep the curated branch as an opt-in
|
||||
filter (or drop it entirely — see Open questions).
|
||||
|
||||
## The convention: a community announcement (kind 3314)
|
||||
|
||||
Not a CORD kind. An announcement is a plain regular event, signed by the sharer,
|
||||
whose **content is a full invite link and nothing else**:
|
||||
|
||||
```
|
||||
3314 | content: "https://host/invite/naddr1…#<fragment>" | tags: []
|
||||
```
|
||||
|
||||
- The link is a bearer secret: anyone who finds it can join, so publishing one is
|
||||
always an explicit user action (there is no "auto-list my community").
|
||||
- Name, icon, channels and relays are **not** in the announcement — they come from
|
||||
resolving the bundle the link points at (kind 33301, `cord05`). This is why an
|
||||
announcement with no valid invite link is dropped.
|
||||
- De-duplication key is the **link signer**: the public key of the kind-33301
|
||||
bundle coordinate's author, parsed out of the naddr. Several announcements of
|
||||
one community (from different sharers, or the same sharer re-posting) fold to
|
||||
one card at render time.
|
||||
- Un-listing is a NIP-09 kind 5 with `e` tags for the announcement ids **and** a
|
||||
`["k", "3314"]` tag. The reader queries deletes by `#k`, so an untagged delete
|
||||
would never be seen. A delete is honored only from the announcement's own
|
||||
author.
|
||||
|
||||
Because the announcement is a convention on top of the existing `cord05` link
|
||||
format, it does **not** belong in `crates/concord` (which stays protocol-only, per
|
||||
`concord-usage.md`). It lives one crate up, in `crates/community`, beside the
|
||||
reader that consumes it.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
announcement 3314 (content = invite link)
|
||||
│ read (per relay) + kind 5 #k:3314 deletes
|
||||
▼
|
||||
fold: drop deleted-by-author, newest per link signer
|
||||
▼
|
||||
DiscoveredInvite { invite_url, link_signer, source }
|
||||
│ parse_link → fetch 33301 → parse_bundle_event
|
||||
▼
|
||||
CommunityInvite (name, icon, channels, relays, owner)
|
||||
│ │
|
||||
▼ ▼
|
||||
card: name / icon / members activity probe: kind 1059 limit:1
|
||||
▼
|
||||
join → existing Concord join path
|
||||
```
|
||||
|
||||
## Where this lands
|
||||
|
||||
| Concern | Location |
|
||||
| --- | --- |
|
||||
| 3314 build/parse/fold (pure) | new `crates/community/src/discover.rs` |
|
||||
| Relay read, paging, activity, seeds | `crates/community` (a `DiscoverRegistry` beside `CommunityRegistry`) |
|
||||
| Bundle resolve + join | `crates/community/src/sync.rs` (reuse the join-material path) |
|
||||
| UI | `crates/workspace/src/panels/browse.rs` (the current placeholder) |
|
||||
|
||||
## Gap analysis
|
||||
|
||||
Coop already has the Concord substrate but has never wired the invite loop.
|
||||
|
||||
| Capability | Status in coop |
|
||||
| --- | --- |
|
||||
| Invite link build/parse (`cord05`) | present: `build_invite_url`, `parse_link`, `bundle_naddr` |
|
||||
| Encrypted bundle 33301 | present: `build_bundle_event`, `parse_bundle_event`, `CommunityInvite` |
|
||||
| Invite list (kind 13303) | present: `InviteList`, `build`/`parse_invite_list`, `InviteEntry` |
|
||||
| **Create + share an invite** | **missing** — no production caller of the writers; no UI |
|
||||
| **Join by invite link** | **missing** — `parse_link` used only in `cord05` tests |
|
||||
| Announcement 3314 | missing |
|
||||
| Discover reader / feed | missing |
|
||||
| Discover UI | placeholder only — `BrowsePanel` renders its own name |
|
||||
|
||||
The invite loop is the prerequisite: until coop can mint a link and join by one,
|
||||
there is nothing publishable to discover.
|
||||
|
||||
## Phase 0 — Invite links end-to-end
|
||||
|
||||
Without this, Discover has no supply.
|
||||
|
||||
**Mint a link** (owner action, from a `CommunityState`):
|
||||
|
||||
```rust
|
||||
use concord::derive::invite_bundle_key;
|
||||
use concord::cord05::{self, CommunityInvite, InviteEntry};
|
||||
|
||||
let token: [u8; concord::derive::TOKEN_LEN] = /* CSPRNG */;
|
||||
let bundle_key = invite_bundle_key(&token);
|
||||
let link_signer = Keys::generate();
|
||||
|
||||
let invite = CommunityInvite { /* from state: community_id, owner, owner_salt,
|
||||
community_root, root_epoch, control_pk, channels, relays, name, icon */ };
|
||||
let bundle = cord05::build_bundle_event(&link_signer, &invite, &bundle_key)?;
|
||||
let url = cord05::build_invite_url(BASE, &link_signer.public_key(), &token, &relays)?;
|
||||
|
||||
// publish `bundle` to the community's relays, then keep the link local:
|
||||
let entry = 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(),
|
||||
};
|
||||
```
|
||||
|
||||
Persist the entry in the member's own Invite List (kind 13303, `build_invite_list`)
|
||||
so it syncs across devices and can later be refreshed or retired (a tombstone,
|
||||
never a deletion). `CommunityState` already carries every field `CommunityInvite`
|
||||
needs (`owner`, `owner_salt`, `community_root`, `root_epoch`, `control_pks`,
|
||||
`channels`, `relays`, `name`, `icon`), so this is assembly plus publish — no new
|
||||
protocol.
|
||||
|
||||
**Resolve + join a link** (member action). Add a resolver beside the reader:
|
||||
|
||||
```rust
|
||||
let link = cord05::parse_link(url)?; // link_signer, token, bootstrap_relays, naddr
|
||||
let bundle_key = invite_bundle_key(&link.token);
|
||||
// fetch kind 33301 at (author = link.link_signer, d = "") from bootstrap_relays:
|
||||
let invite = match cord05::parse_bundle_event(&event, &link.link_signer, &bundle_key)? {
|
||||
BundleState::Live(invite) => invite,
|
||||
BundleState::Revoked => return Ok(None),
|
||||
};
|
||||
```
|
||||
|
||||
Then materialize and announce the membership exactly as `concord-usage.md`'s
|
||||
"Joining" section does: `cord02::list::join_material(&invite, None)` →
|
||||
`CommunityState::from_join_material`, publish the guestbook join
|
||||
(`cord02::guestbook::build_join` + `seal_rumor`), and record the membership the
|
||||
way `sync::create` does (`record_membership`). Reuse the `adopt_list_material` /
|
||||
`retain_join_root` helpers in `crates/community/src/sync.rs`.
|
||||
|
||||
**UI** for Phase 0: a "Copy invite link" action on a community (panel toolbar or
|
||||
context menu) and a paste-link join dialog under `crates/workspace/src/dialogs/`.
|
||||
A deep link (`https://host/invite/…#…`, or a `coop://` scheme) can feed the same
|
||||
dialog.
|
||||
|
||||
## Phase 1 — The 3314 conventions (`crates/community/src/discover.rs`)
|
||||
|
||||
A pure module — no I/O, mirroring `cord05`'s split between parse and fetch:
|
||||
|
||||
```rust
|
||||
pub const KIND_COMMUNITY_ANNOUNCEMENT: u16 = 3314;
|
||||
|
||||
pub struct DiscoveredInvite {
|
||||
pub invite_url: String,
|
||||
pub link_signer: PublicKey, // de-dup key
|
||||
pub source: Event,
|
||||
}
|
||||
|
||||
pub fn extract_invite_urls(text: &str) -> Vec<String>;
|
||||
pub fn announcement_from_event(event: &Event) -> Option<DiscoveredInvite>;
|
||||
pub fn build_community_announcement(invite_url: &str) -> Option<EventBuilder>;
|
||||
pub fn build_announcement_deletion(ids: &[EventId]) -> EventBuilder; // kind 5 + ["k","3314"]
|
||||
|
||||
/// Newest per link signer, honoring deletes only from the announcement's author.
|
||||
pub fn fold_announcements(announcements: &[Event], deletes: &[Event]) -> Vec<DiscoveredInvite>;
|
||||
```
|
||||
|
||||
The invite-URL matcher accepts both the absolute form and the bare `naddr1…#frag`
|
||||
form found in free text, and validates each candidate with `parse_link` so only
|
||||
secret-carrying links survive.
|
||||
|
||||
## Phase 2 — The reader (`crates/community`)
|
||||
|
||||
A `DiscoverRegistry`, global like `CommunityRegistry`, owning the network side:
|
||||
|
||||
- **Relays.** The account's NIP-65 read relays plus any configured app relays.
|
||||
The fragment's `bootstrap_relays` (or `cord05::stock_relays()`) are used only to
|
||||
fetch the bundle, never to enumerate.
|
||||
- **Read + page.** Per relay (a dead relay must cost only its own events): kind
|
||||
3314, plus kind 5 with `#k: ["3314"]`. Page with an `until` cursor so "load
|
||||
more" walks older announcements; dedupe by link signer across pages.
|
||||
- **Resolve bundles.** Fetch kind 33301 at the link coordinate, decrypt with the
|
||||
token-derived key, cache by link signer (name/icon feed the cards; missing
|
||||
bundle ⇒ an "encrypted community" placeholder, as Armada does).
|
||||
- **Activity.** A batched `limit: 1` kind-1059 probe per community — stream
|
||||
addresses are derivable from the bundle (`derive::{guestbook_group_key,
|
||||
control_group_key, channel_group_key}`) without joining. Cap filters per REQ and
|
||||
bound `until` to now, since a future-dated wrap would otherwise pin "Active now".
|
||||
- **Warm paint.** Seed the last good directory so the first paint is not a
|
||||
skeleton; a seed is stale-but-usable and the network read stays authoritative.
|
||||
Store it wherever `community::cache` stores local state, or in the client
|
||||
database.
|
||||
- **No allow-list.** The default path applies no `authors` filter and never reads
|
||||
a curation pack. An opt-in curated mode can be added later behind a setting.
|
||||
- Emit `CommunityEvent`-style notifications so the sidebar/panel re-render.
|
||||
|
||||
## Phase 3 — UI (`BrowsePanel`)
|
||||
|
||||
Turn the placeholder `crates/workspace/src/panels/browse.rs` (already reached
|
||||
from the sidebar "Browse" `NavItem` → `Command::ShowBrowse`) into the feed:
|
||||
|
||||
- A scrolling list of cards: icon, name, member count, "Active N ago", and a Join
|
||||
button that runs the Phase 0 resolve-and-join on the card's link.
|
||||
- Loading skeletons, an empty state, and an error state that only shows when
|
||||
there is nothing already painted.
|
||||
- A search box (client-side over the resolved name, since announcements carry no
|
||||
metadata).
|
||||
- A "Share to Discover" dialog from a community the user can publish for, and a
|
||||
"Remove from Discover" that publishes the kind-5 deletion for their own
|
||||
announcements.
|
||||
- Settings: optionally a curation-source field with the default **off**
|
||||
(show all). Rename the panel/sidebar item to "Discover" if desired.
|
||||
|
||||
## Tests
|
||||
|
||||
- `fold_announcements`: newest-per-link-signer, delete honored only from the same
|
||||
author, delete-via-`#k` tag path.
|
||||
- Invite-URL extraction: absolute and bare forms, rejects non-invite naddrs.
|
||||
- Bundle resolve: live vs revoked tombstone; missing bundle.
|
||||
- Paging: `until` cursor, per-relay failure isolation, cross-page dedupe.
|
||||
- Activity batching: filters-per-REQ cap, future-dated wrap rejected.
|
||||
- A GPUI integration test driving `BrowsePanel` from a seeded registry.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Discover relays** — account NIP-65 read relays + configured app relays, or a
|
||||
hardcoded Concord bootstrap set?
|
||||
2. **Curation** — ship purely unfiltered, or keep Armada's opt-in author filter
|
||||
(default off) for users who want a curated feed?
|
||||
3. **Activity in v1** — include the kind-1059 probe now, or ship name/icon/members
|
||||
first and add "Active N ago" later?
|
||||
4. **Naming** — keep the existing "Browse" label, or rename to "Discover"?
|
||||
5. **Deletion relay reach** — the kind-5 `#k` read must run against the same relay
|
||||
set as the 3314 read, or an un-list can be missed. Confirm one set is used for
|
||||
both.
|
||||
|
||||
Reference in New Issue
Block a user