clean up and add community invite
This commit is contained in:
Generated
+1
@@ -1284,6 +1284,7 @@ version = "1.0.2"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"concord",
|
"concord",
|
||||||
|
"data-encoding",
|
||||||
"flume 0.11.1",
|
"flume 0.11.1",
|
||||||
"futures",
|
"futures",
|
||||||
"gpui-pre",
|
"gpui-pre",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ gpui.workspace = true
|
|||||||
nostr-sdk.workspace = true
|
nostr-sdk.workspace = true
|
||||||
|
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
|
data-encoding.workspace = true
|
||||||
flume.workspace = true
|
flume.workspace = true
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
|||||||
@@ -112,6 +112,10 @@ pub enum CommunityEvent {
|
|||||||
Unreadable(CommunityId),
|
Unreadable(CommunityId),
|
||||||
/// The last round could not reach the community's relays.
|
/// The last round could not reach the community's relays.
|
||||||
Failed(CommunityId),
|
Failed(CommunityId),
|
||||||
|
Invite {
|
||||||
|
id: CommunityId,
|
||||||
|
url: String,
|
||||||
|
},
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,8 +340,7 @@ impl Community {
|
|||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let progress = match receiver.recv_async().await {
|
let progress = match receiver.recv_async().await {
|
||||||
Ok(progress) => progress,
|
Ok(progress) => progress,
|
||||||
Err(error) => {
|
Err(_) => {
|
||||||
log::warn!("community: channel round cancelled: {error}");
|
|
||||||
return Ok(Progress {
|
return Ok(Progress {
|
||||||
failed: true,
|
failed: true,
|
||||||
..Progress::default()
|
..Progress::default()
|
||||||
@@ -377,9 +380,8 @@ impl Community {
|
|||||||
let finisher = cx.spawn(async move |this, cx| {
|
let finisher = cx.spawn(async move |this, cx| {
|
||||||
let result = round.await;
|
let result = round.await;
|
||||||
|
|
||||||
if let Err(error) = this.update(cx, |this, cx| this.finish_round(channel, result, cx)) {
|
this.update(cx, |this, cx| this.finish_round(channel, result, cx))
|
||||||
log::warn!("community: a channel round outlived its community: {error}");
|
.ok();
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
@@ -410,9 +412,7 @@ impl Community {
|
|||||||
let waiters = std::mem::take(&mut round.waiters);
|
let waiters = std::mem::take(&mut round.waiters);
|
||||||
|
|
||||||
for waiter in waiters {
|
for waiter in waiters {
|
||||||
if let Err(error) = waiter.try_send(outcome.clone()) {
|
waiter.try_send(outcome.clone()).ok();
|
||||||
log::warn!("community: a channel round result was not delivered: {error}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(intent) = queued {
|
if let Some(intent) = queued {
|
||||||
@@ -620,7 +620,6 @@ impl Community {
|
|||||||
let (opened, _) = cord03::open(&wrap, &group, &channel, epoch)?;
|
let (opened, _) = cord03::open(&wrap, &group, &channel, epoch)?;
|
||||||
cache::cache_rumor(&client, &channel, &opened).await?;
|
cache::cache_rumor(&client, &channel, &opened).await?;
|
||||||
|
|
||||||
sync::connect_relays(&client, &relays).await;
|
|
||||||
sync::publish_wrap(&client, &wrap, &relays).await;
|
sync::publish_wrap(&client, &wrap, &relays).await;
|
||||||
|
|
||||||
Ok(opened.rumor_id)
|
Ok(opened.rumor_id)
|
||||||
@@ -967,14 +966,11 @@ impl Community {
|
|||||||
};
|
};
|
||||||
|
|
||||||
self.icon_task = Some(cx.spawn(async move |this, cx| {
|
self.icon_task = Some(cx.spawn(async move |this, cx| {
|
||||||
match sync::resolve_image(&icon, cx).await {
|
if let Ok(path) = sync::resolve_image(&icon, cx).await {
|
||||||
Ok(path) => {
|
this.update(cx, |this, cx| {
|
||||||
this.update(cx, |this, cx| {
|
this.icon = Some(path);
|
||||||
this.icon = Some(path);
|
cx.notify();
|
||||||
cx.notify();
|
})?;
|
||||||
})?;
|
|
||||||
}
|
|
||||||
Err(error) => log::warn!("community icon: {error}"),
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
@@ -993,14 +989,11 @@ impl Community {
|
|||||||
};
|
};
|
||||||
|
|
||||||
self.banner_task = Some(cx.spawn(async move |this, cx| {
|
self.banner_task = Some(cx.spawn(async move |this, cx| {
|
||||||
match sync::resolve_image(&banner, cx).await {
|
if let Ok(path) = sync::resolve_image(&banner, cx).await {
|
||||||
Ok(path) => {
|
this.update(cx, |this, cx| {
|
||||||
this.update(cx, |this, cx| {
|
this.banner = Some(path);
|
||||||
this.banner = Some(path);
|
cx.notify();
|
||||||
cx.notify();
|
})?;
|
||||||
})?;
|
|
||||||
}
|
|
||||||
Err(error) => log::warn!("community banner: {error}"),
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -197,10 +197,7 @@ pub async fn page(
|
|||||||
log::warn!("community: relay {url} refused a history page: {reason}");
|
log::warn!("community: relay {url} refused a history page: {reason}");
|
||||||
walk.reject(*index);
|
walk.reject(*index);
|
||||||
}
|
}
|
||||||
None => {
|
None => walk.reject(*index),
|
||||||
log::warn!("community: relay {url} did not finish a history page");
|
|
||||||
walk.reject(*index);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -257,6 +257,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.
|
/// Forget the current account and cancel everything in flight.
|
||||||
pub fn reset(&mut self, cx: &mut Context<Self>) {
|
pub fn reset(&mut self, cx: &mut Context<Self>) {
|
||||||
self.notification_listener = None;
|
self.notification_listener = None;
|
||||||
@@ -644,18 +698,11 @@ async fn subscribe(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let output = client
|
client
|
||||||
.subscribe(ReqTarget::manual(targets))
|
.subscribe(ReqTarget::manual(targets))
|
||||||
.with_id(id.clone())
|
.with_id(id.clone())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if !output.failed.is_empty() {
|
|
||||||
log::warn!(
|
|
||||||
"community {id}: {} relay(s) rejected the subscription",
|
|
||||||
output.failed.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -447,10 +447,6 @@ pub async fn rotate(
|
|||||||
if let RotationPlan::Base(refounding) = &plan {
|
if let RotationPlan::Base(refounding) = &plan {
|
||||||
let carried = carry_heads(client, state, refounding, at).await?;
|
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);
|
wraps.extend(carried);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+201
-30
@@ -1,5 +1,6 @@
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use concord::cord01::KIND_WRAP_EPHEMERAL;
|
use concord::cord01::KIND_WRAP_EPHEMERAL;
|
||||||
@@ -7,11 +8,16 @@ use concord::cord02::list::{CommunityList, JoinMaterial, KIND_COMMUNITY_LIST};
|
|||||||
use concord::cord02::{self, ControlFold, ImageRef};
|
use concord::cord02::{self, ControlFold, ImageRef};
|
||||||
use concord::cord04::AuthorityCitation;
|
use concord::cord04::AuthorityCitation;
|
||||||
use concord::cord04::roles::{Permissions, citation_ok};
|
use concord::cord04::roles::{Permissions, citation_ok};
|
||||||
|
use concord::cord05::{
|
||||||
|
self, BundleState, ChannelGrant, CommunityInvite, InviteEntry, InviteList, ParsedInviteLink,
|
||||||
|
};
|
||||||
use concord::derive::{
|
use concord::derive::{
|
||||||
channel_group_key, control_group_key, control_signer_group_key, guestbook_group_key,
|
TOKEN_LEN, 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::state::{CommunityState, HeldKey, HeldRoot, list_entry};
|
||||||
use concord::{ChannelId, CommunityId, Epoch, GroupKey, decode_hex_32};
|
use concord::{ChannelId, CommunityId, Epoch, GroupKey, decode_hex_32};
|
||||||
|
use data_encoding::HEXLOWER;
|
||||||
use gpui::AsyncApp;
|
use gpui::AsyncApp;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use state::UniversalSigner;
|
use state::UniversalSigner;
|
||||||
@@ -21,6 +27,7 @@ use crate::history::{CURSOR_OVERLAP, Window};
|
|||||||
|
|
||||||
/// How much of what a relay stores a cold subscription replays per relay.
|
/// How much of what a relay stores a cold subscription replays per relay.
|
||||||
const LIVE_REPLAY: usize = 500;
|
const LIVE_REPLAY: usize = 500;
|
||||||
|
const INVITE_BASE: &str = "https://coopchat.xyz";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum PlaneKind {
|
pub enum PlaneKind {
|
||||||
@@ -213,10 +220,194 @@ where
|
|||||||
Ok(state)
|
Ok(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 mut token = [0u8; TOKEN_LEN];
|
||||||
|
token.copy_from_slice(&link_signer.secret_key().as_secret_bytes()[..TOKEN_LEN]);
|
||||||
|
|
||||||
|
let bundle_key = invite_bundle_key(&token);
|
||||||
|
let relays: Vec<String> = state.relays.iter().map(|relay| relay.to_string()).collect();
|
||||||
|
|
||||||
|
let channels = state
|
||||||
|
.channels
|
||||||
|
.iter()
|
||||||
|
.map(|channel| ChannelGrant {
|
||||||
|
id: channel.id,
|
||||||
|
key: channel.key.map(|key| HEXLOWER.encode(&key)),
|
||||||
|
epoch: channel.epoch,
|
||||||
|
name: channel.name.clone(),
|
||||||
|
extra: Default::default(),
|
||||||
|
})
|
||||||
|
.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,
|
||||||
|
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().saturating_mul(1000),
|
||||||
|
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_invite(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_invite(client: &Client, link: &ParsedInviteLink) -> Result<CommunityInvite> {
|
||||||
|
let relays: Vec<RelayUrl> = link
|
||||||
|
.bootstrap_relays
|
||||||
|
.iter()
|
||||||
|
.filter_map(|relay| RelayUrl::parse(relay).ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if relays.is_empty() {
|
||||||
|
anyhow::bail!("invite link names no relays");
|
||||||
|
}
|
||||||
|
|
||||||
|
connect_relays(client, &relays).await;
|
||||||
|
|
||||||
|
let filter = Filter::new()
|
||||||
|
.kind(Kind::Custom(cord05::KIND_BUNDLE))
|
||||||
|
.author(link.link_signer)
|
||||||
|
.identifier("");
|
||||||
|
|
||||||
|
let targets: Vec<(RelayUrl, Vec<Filter>)> = relays
|
||||||
|
.into_iter()
|
||||||
|
.map(|relay| (relay, vec![filter.clone()]))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let events = client
|
||||||
|
.fetch_events(targets)
|
||||||
|
.timeout(Duration::from_secs(10))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some(event) = events.into_iter().max_by_key(|event| event.created_at) else {
|
||||||
|
anyhow::bail!("invite link 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)
|
||||||
|
}
|
||||||
|
|
||||||
/// Best-effort publication of the genesis wraps to the community's relays.
|
/// 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(crate) async fn publish_wraps(client: &Client, wraps: &[Event], relays: &[RelayUrl]) {
|
||||||
connect_relays(client, relays).await;
|
|
||||||
|
|
||||||
for wrap in wraps {
|
for wrap in wraps {
|
||||||
publish_wrap(client, wrap, relays).await;
|
publish_wrap(client, wrap, relays).await;
|
||||||
}
|
}
|
||||||
@@ -233,20 +424,16 @@ pub(crate) async fn connect_relays(client: &Client, relays: &[RelayUrl]) {
|
|||||||
|
|
||||||
/// Best-effort publication of a single wrap to the community's relays.
|
/// Best-effort publication of a single wrap to the community's relays.
|
||||||
pub(crate) async fn publish_wrap(client: &Client, wrap: &Event, relays: &[RelayUrl]) {
|
pub(crate) async fn publish_wrap(client: &Client, wrap: &Event, relays: &[RelayUrl]) {
|
||||||
|
connect_relays(client, relays).await;
|
||||||
|
|
||||||
let sent = if relays.is_empty() {
|
let sent = if relays.is_empty() {
|
||||||
client.send_event(wrap).broadcast().await
|
client.send_event(wrap).broadcast().await
|
||||||
} else {
|
} else {
|
||||||
client.send_event(wrap).to(relays.iter().cloned()).await
|
client.send_event(wrap).to(relays.iter().cloned()).await
|
||||||
};
|
};
|
||||||
|
|
||||||
match sent {
|
if let Err(error) = sent {
|
||||||
Ok(output) if output.failed.is_empty() => {}
|
log::warn!("community: publishing {} failed: {error}", wrap.id);
|
||||||
Ok(output) => log::warn!(
|
|
||||||
"community: {} relay(s) rejected {}",
|
|
||||||
output.failed.len(),
|
|
||||||
wrap.id
|
|
||||||
),
|
|
||||||
Err(error) => log::warn!("community: publishing {} failed: {error}", wrap.id),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,10 +452,6 @@ where
|
|||||||
let frags = held.as_ref().map_or(1, |list| list.frags);
|
let frags = held.as_ref().map_or(1, |list| list.frags);
|
||||||
|
|
||||||
if frags > 1 {
|
if frags > 1 {
|
||||||
log::warn!(
|
|
||||||
"community {}: the list spans {frags} fragments; deferring the membership write",
|
|
||||||
state.id.to_hex()
|
|
||||||
);
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,13 +472,8 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn publish_list(client: &Client, event: &Event) {
|
async fn publish_list(client: &Client, event: &Event) {
|
||||||
match client.send_event(event).to_nip65().await {
|
if let Err(error) = client.send_event(event).to_nip65().await {
|
||||||
Ok(output) if output.failed.is_empty() => {}
|
log::warn!("community list: publish failed: {error}");
|
||||||
Ok(output) => log::warn!(
|
|
||||||
"community list: {} relay(s) rejected the publish",
|
|
||||||
output.failed.len()
|
|
||||||
),
|
|
||||||
Err(error) => log::warn!("community list: publish failed: {error}"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,18 +506,11 @@ pub async fn subscribe_list(client: &Client, self_pk: PublicKey) -> Result<()> {
|
|||||||
.kind(Kind::Custom(KIND_COMMUNITY_LIST))
|
.kind(Kind::Custom(KIND_COMMUNITY_LIST))
|
||||||
.author(self_pk);
|
.author(self_pk);
|
||||||
|
|
||||||
let output = client
|
client
|
||||||
.subscribe(ReqTarget::auto(vec![filter]))
|
.subscribe(ReqTarget::auto(vec![filter]))
|
||||||
.with_id(id)
|
.with_id(id)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if !output.failed.is_empty() {
|
|
||||||
log::warn!(
|
|
||||||
"community list: {} relay(s) rejected the subscription",
|
|
||||||
output.failed.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ use std::fmt;
|
|||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use community::{
|
use community::{
|
||||||
ChannelId, ChatMessage, Community, CommunityEvent, Epoch, Intent, LOAD_OLDER_PAGES,
|
ChannelId, ChatMessage, Community, CommunityEvent, CommunityRegistry, Epoch, Intent,
|
||||||
TIMELINE_PAGE, Timeline,
|
LOAD_OLDER_PAGES, TIMELINE_PAGE, Timeline,
|
||||||
};
|
};
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, FollowMode,
|
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||||
IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, Render, SharedString,
|
Focusable, FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement,
|
||||||
Styled, Subscription, Task, WeakEntity, Window, div, list, px,
|
Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list, px,
|
||||||
};
|
};
|
||||||
use nostr_sdk::prelude::EventId;
|
use nostr_sdk::prelude::EventId;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
@@ -112,11 +112,13 @@ pub struct CommunityPanel {
|
|||||||
/// Spawned reads and publishes, cancelled when the panel closes
|
/// Spawned reads and publishes, cancelled when the panel closes
|
||||||
tasks: SmallVec<[Task<Result<()>>; 4]>,
|
tasks: SmallVec<[Task<Result<()>>; 4]>,
|
||||||
/// Event subscriptions
|
/// Event subscriptions
|
||||||
_subscriptions: SmallVec<[Subscription; 2]>,
|
_subscriptions: SmallVec<[Subscription; 3]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommunityPanel {
|
impl CommunityPanel {
|
||||||
pub fn new(community: Entity<Community>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
pub fn new(community: Entity<Community>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let store = CommunityRegistry::global(cx);
|
||||||
|
|
||||||
let (id, name, channel) = {
|
let (id, name, channel) = {
|
||||||
let community = community.read(cx);
|
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| {
|
cx.defer_in(window, |this, window, cx| {
|
||||||
this.list_state.set_follow_mode(FollowMode::Tail);
|
this.list_state.set_follow_mode(FollowMode::Tail);
|
||||||
this.list_state.set_scroll_handler(cx.listener(
|
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> {
|
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));
|
||||||
|
}),
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 import;
|
||||||
|
pub mod join_community;
|
||||||
pub mod new_chat;
|
pub mod new_chat;
|
||||||
pub mod new_community;
|
pub mod new_community;
|
||||||
pub mod restore;
|
pub mod restore;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ use ui::notification::{Notification, NotificationKind};
|
|||||||
use ui::{IconName, Root, Sizable, WindowExtension, h_flex, v_flex};
|
use ui::{IconName, Root, Sizable, WindowExtension, h_flex, v_flex};
|
||||||
|
|
||||||
use crate::dialogs::restore::RestoreEncryption;
|
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::{
|
use crate::panels::{
|
||||||
backup, browse, contact_list, greeter, inbox, messaging_relays, profile, relay_list, requests,
|
backup, browse, contact_list, greeter, inbox, messaging_relays, profile, relay_list, requests,
|
||||||
search,
|
search,
|
||||||
@@ -61,6 +61,7 @@ enum Command {
|
|||||||
ShowSearch,
|
ShowSearch,
|
||||||
NewChat,
|
NewChat,
|
||||||
NewCommunity,
|
NewCommunity,
|
||||||
|
JoinCommunity,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Workspace {
|
pub struct Workspace {
|
||||||
@@ -294,6 +295,9 @@ impl Workspace {
|
|||||||
Command::NewCommunity => {
|
Command::NewCommunity => {
|
||||||
new_community::open(window, cx);
|
new_community::open(window, cx);
|
||||||
}
|
}
|
||||||
|
Command::JoinCommunity => {
|
||||||
|
join_community::open(window, cx);
|
||||||
|
}
|
||||||
Command::ShowBackup => {
|
Command::ShowBackup => {
|
||||||
self.add_panel_to_dock(backup::init(window, cx), DockPlacement::Left, window, cx);
|
self.add_panel_to_dock(backup::init(window, cx), DockPlacement::Left, window, cx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -586,7 +586,7 @@ impl Sidebar {
|
|||||||
.gap_1()
|
.gap_1()
|
||||||
.child(
|
.child(
|
||||||
NavItem::new(
|
NavItem::new(
|
||||||
"new-community",
|
"new",
|
||||||
"New Community",
|
"New Community",
|
||||||
Icon::new(IconName::Group).small(),
|
Icon::new(IconName::Group).small(),
|
||||||
)
|
)
|
||||||
@@ -594,6 +594,16 @@ impl Sidebar {
|
|||||||
window.dispatch_action(Box::new(Command::NewCommunity), cx)
|
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(
|
.child(
|
||||||
NavItem::new("browse", "Browse", Icon::new(IconName::Compass).small())
|
NavItem::new("browse", "Browse", Icon::new(IconName::Compass).small())
|
||||||
.on_click(|_, window, cx| {
|
.on_click(|_, window, cx| {
|
||||||
|
|||||||
Reference in New Issue
Block a user