adopt two-step NIP-29 group creation with confirmation
This commit is contained in:
+83
-12
@@ -23,6 +23,10 @@ pub(crate) static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
||||
const BROWSE_LIMIT: usize = 500;
|
||||
const BROWSE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const FORK_INTERVAL: Duration = Duration::from_secs(15 * 60);
|
||||
const CONFIRM_POLLS: usize = 20;
|
||||
const CONFIRM_INTERVAL: Duration = Duration::from_millis(250);
|
||||
const METADATA_WARNING: &str =
|
||||
"Group created, but the relay did not accept its name. Edit the group to set it.";
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
GroupsRegistry::set_global(cx.new(GroupsRegistry::new), cx);
|
||||
@@ -46,6 +50,11 @@ pub enum GroupsEvent {
|
||||
},
|
||||
/// The discovery synced new groups into the local database.
|
||||
Synced,
|
||||
/// A relay accepted a new group; the warning carries a non-fatal issue.
|
||||
Created {
|
||||
key: GroupKey,
|
||||
warning: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -191,31 +200,93 @@ impl GroupsRegistry {
|
||||
}));
|
||||
}
|
||||
|
||||
pub fn create(&mut self, key: GroupKey, metadata: GroupMetadata, cx: &mut Context<Self>) {
|
||||
pub fn create(&mut self, relay: RelayUrl, options: GroupCreateOptions, cx: &mut Context<Self>) {
|
||||
let name = options.name.trim().to_owned();
|
||||
|
||||
if name.is_empty() {
|
||||
cx.emit(GroupsEvent::Error("group name is empty".to_owned()));
|
||||
return;
|
||||
}
|
||||
|
||||
let raw = options.custom_id.as_deref().unwrap_or_default();
|
||||
let id = if raw.is_empty() {
|
||||
readable_id()
|
||||
} else {
|
||||
raw.trim().to_lowercase()
|
||||
};
|
||||
|
||||
let id = match GroupId::new(id) {
|
||||
Ok(id) => id,
|
||||
Err(error) => {
|
||||
cx.emit(GroupsEvent::Error(error.to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let key = GroupKey::new(relay, id);
|
||||
self.ensure(key.clone(), cx);
|
||||
|
||||
let Some(group) = self.group(&key, cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.upsert_entry(&key, metadata.name().map(str::to_owned));
|
||||
self.publish_list(cx);
|
||||
|
||||
cx.emit(GroupsEvent::Updated);
|
||||
cx.notify();
|
||||
|
||||
let builder = key.id().create_group(&metadata, None);
|
||||
let metadata = options.metadata(name.clone());
|
||||
let builder = key.id().create_group(None);
|
||||
let published = group.read(cx).publish(builder, cx);
|
||||
let logged = key.clone();
|
||||
let created = key;
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if let Err(e) = published.await {
|
||||
if let Err(error) = published.await {
|
||||
this.update(cx, |this, cx| {
|
||||
this.forget(logged, cx);
|
||||
cx.emit(GroupsEvent::Error(e.to_string()));
|
||||
this.forget(created, cx);
|
||||
cx.emit(GroupsEvent::Error(error.to_string()));
|
||||
})
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
|
||||
// Give the relay time to publish the group's metadata event,
|
||||
// which settles the group id (the relay may override the suggested one).
|
||||
for _ in 0..CONFIRM_POLLS {
|
||||
if group.read_with(cx, |group, _cx| group.metadata().is_some()) {
|
||||
break;
|
||||
}
|
||||
cx.background_executor().timer(CONFIRM_INTERVAL).await;
|
||||
}
|
||||
|
||||
// The relay can reject the metadata while the admin grant from the
|
||||
// create is still settling, one delayed retry covers that race.
|
||||
let mut warning = None;
|
||||
|
||||
for attempt in 0..2 {
|
||||
if attempt > 0 {
|
||||
cx.background_executor().timer(Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
let published = group.update(cx, |group, cx| group.edit_metadata(&metadata, cx));
|
||||
let accepted = published.await.is_ok();
|
||||
|
||||
if accepted {
|
||||
break;
|
||||
}
|
||||
|
||||
if attempt == 1 {
|
||||
warning = Some(METADATA_WARNING.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.upsert_entry(&created, Some(name));
|
||||
this.publish_list(cx);
|
||||
|
||||
cx.emit(GroupsEvent::Updated);
|
||||
cx.emit(GroupsEvent::Created {
|
||||
key: created,
|
||||
warning,
|
||||
});
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -155,9 +155,9 @@ impl GroupId {
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
|
||||
pub fn create_group(&self, metadata: &GroupMetadata, previous: Option<Tag>) -> EventBuilder {
|
||||
pub fn create_group(&self, previous: Option<Tag>) -> EventBuilder {
|
||||
EventBuilder::new(Kind::GroupCreateGroup, "")
|
||||
.tags(self.metadata_tags(metadata))
|
||||
.tag(self.h_tag())
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
|
||||
@@ -435,6 +435,36 @@ impl GroupMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for creating a group: access flags are presence-only per NIP-29.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GroupCreateOptions {
|
||||
pub name: String,
|
||||
pub about: Option<String>,
|
||||
pub picture: Option<String>,
|
||||
pub private: bool,
|
||||
pub closed: bool,
|
||||
pub restricted: bool,
|
||||
pub hidden: bool,
|
||||
pub custom_id: Option<String>,
|
||||
pub parent: Option<GroupId>,
|
||||
}
|
||||
|
||||
impl GroupCreateOptions {
|
||||
pub(crate) fn metadata(&self, name: String) -> GroupMetadata {
|
||||
GroupMetadata {
|
||||
name: Some(name),
|
||||
picture: self.picture.clone(),
|
||||
about: self.about.clone(),
|
||||
private: self.private,
|
||||
restricted: self.restricted,
|
||||
hidden: self.hidden,
|
||||
closed: self.closed,
|
||||
parent: self.parent.clone(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GroupCandidate {
|
||||
pub key: GroupKey,
|
||||
|
||||
@@ -58,3 +58,44 @@ pub(crate) fn parse_members(event: &Event) -> Option<(GroupId, Timestamp, usize)
|
||||
|
||||
Some((id, event.created_at, members))
|
||||
}
|
||||
|
||||
const ONSETS: [&str; 34] = [
|
||||
"b", "c", "d", "f", "g", "h", "j", "k", "l", "m", "n", "p", "r", "s", "t", "v", "br", "cr",
|
||||
"dr", "fr", "gr", "pr", "tr", "bl", "cl", "fl", "gl", "pl", "sl", "sh", "ch", "th", "st", "sk",
|
||||
];
|
||||
const NUCLEI: [&str; 9] = ["a", "e", "i", "o", "u", "ae", "ei", "io", "ou"];
|
||||
const CODAS: [&str; 11] = ["", "", "", "n", "r", "s", "t", "d", "l", "m", "k"];
|
||||
|
||||
/// A short pronounceable id for new groups, seeded from a fresh key.
|
||||
pub(crate) fn readable_id() -> String {
|
||||
let mut picks = SecretKey::generate().secret_bytes().into_iter();
|
||||
|
||||
let mut id = String::with_capacity(18);
|
||||
|
||||
for _ in 0..3 {
|
||||
let onset = ONSETS[usize::from(picks.next().unwrap_or(0)) % ONSETS.len()];
|
||||
let nucleus = NUCLEI[usize::from(picks.next().unwrap_or(0)) % NUCLEI.len()];
|
||||
let coda = CODAS[usize::from(picks.next().unwrap_or(0)) % CODAS.len()];
|
||||
|
||||
id.push_str(onset);
|
||||
id.push_str(nucleus);
|
||||
id.push_str(coda);
|
||||
}
|
||||
|
||||
id
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_readable_id() {
|
||||
for _ in 0..32 {
|
||||
let id = readable_id();
|
||||
|
||||
assert!((6..=15).contains(&id.len()));
|
||||
assert!(id.chars().all(|character| character.is_ascii_lowercase()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{App, AppContext, ClickEvent, Div, Entity, ParentElement, Styled, Window, px};
|
||||
use nip29::{GroupId, GroupKey, GroupMetadata, GroupsRegistry};
|
||||
use nip29::{GroupCreateOptions, GroupsRegistry};
|
||||
use nostr_sdk::prelude::*;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
use ui::button::{Button, ButtonVariants, IconPosition};
|
||||
@@ -90,20 +90,6 @@ fn create(
|
||||
return false;
|
||||
}
|
||||
|
||||
let id = if raw_id.is_empty() {
|
||||
slug(&name_value)
|
||||
} else {
|
||||
raw_id
|
||||
};
|
||||
|
||||
let id = match GroupId::new(id) {
|
||||
Ok(id) => id,
|
||||
Err(error) => {
|
||||
window.push_notification(Notification::error(error.to_string()), cx);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let relay = match RelayUrl::parse(&raw_relay) {
|
||||
Ok(relay) => relay,
|
||||
Err(error) => {
|
||||
@@ -112,30 +98,20 @@ fn create(
|
||||
}
|
||||
};
|
||||
|
||||
let mut metadata = GroupMetadata::default();
|
||||
metadata.name = Some(name_value);
|
||||
let options = GroupCreateOptions {
|
||||
name: name_value,
|
||||
custom_id: if raw_id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(raw_id)
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let registry = GroupsRegistry::global(cx);
|
||||
registry.update(cx, |registry, cx| {
|
||||
registry.create(GroupKey::new(relay, id), metadata, cx);
|
||||
registry.create(relay, options, cx);
|
||||
});
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn slug(name: &str) -> String {
|
||||
let mut slug = String::with_capacity(name.len());
|
||||
let mut separator = false;
|
||||
|
||||
for character in name.chars() {
|
||||
if character.is_ascii_alphanumeric() {
|
||||
slug.push(character.to_ascii_lowercase());
|
||||
separator = false;
|
||||
} else if !separator && !slug.is_empty() {
|
||||
slug.push('-');
|
||||
separator = true;
|
||||
}
|
||||
}
|
||||
|
||||
slug.trim_end_matches('-').to_owned()
|
||||
}
|
||||
|
||||
@@ -127,6 +127,12 @@ impl Sidebar {
|
||||
GroupsEvent::Error(error) => {
|
||||
window.push_notification(Notification::error(error.clone()), cx);
|
||||
}
|
||||
GroupsEvent::Created {
|
||||
warning: Some(warning),
|
||||
..
|
||||
} => {
|
||||
window.push_notification(Notification::warning(warning.clone()), cx);
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user