add encrypted private section to group lists and channels
This commit is contained in:
+92
-12
@@ -6,7 +6,7 @@ use anyhow::{Error, Result};
|
|||||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
|
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
use state::NostrRegistry;
|
use state::{NostrRegistry, UniversalSigner};
|
||||||
|
|
||||||
mod discovery;
|
mod discovery;
|
||||||
mod group;
|
mod group;
|
||||||
@@ -170,7 +170,7 @@ impl GroupsRegistry {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
self.upsert_entry(&key, None);
|
self.upsert_entry(&key, None, false);
|
||||||
self.publish_list(cx);
|
self.publish_list(cx);
|
||||||
|
|
||||||
cx.emit(GroupsEvent::Updated);
|
cx.emit(GroupsEvent::Updated);
|
||||||
@@ -231,6 +231,7 @@ impl GroupsRegistry {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let metadata = options.metadata(name.clone());
|
let metadata = options.metadata(name.clone());
|
||||||
|
let list_private = options.list_private;
|
||||||
let builder = key.id().create_group(None);
|
let builder = key.id().create_group(None);
|
||||||
let published = group.read(cx).publish(builder, cx);
|
let published = group.read(cx).publish(builder, cx);
|
||||||
let created = key;
|
let created = key;
|
||||||
@@ -276,7 +277,7 @@ impl GroupsRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.upsert_entry(&created, Some(name));
|
this.upsert_entry(&created, Some(name), list_private);
|
||||||
this.publish_list(cx);
|
this.publish_list(cx);
|
||||||
|
|
||||||
cx.emit(GroupsEvent::Updated);
|
cx.emit(GroupsEvent::Updated);
|
||||||
@@ -463,7 +464,10 @@ impl GroupsRegistry {
|
|||||||
let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1);
|
let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1);
|
||||||
|
|
||||||
match client.database().query(filter).await?.into_iter().next() {
|
match client.database().query(filter).await?.into_iter().next() {
|
||||||
Some(event) => GroupList::parse(&event),
|
Some(event) => {
|
||||||
|
let list = GroupList::parse(&event)?;
|
||||||
|
Ok(Self::hydrate_list(list, &signer, &me).await)
|
||||||
|
}
|
||||||
None => Ok(GroupList::default()),
|
None => Ok(GroupList::default()),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -506,6 +510,10 @@ impl GroupsRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn merge(&mut self, list: GroupList, cx: &mut Context<Self>) {
|
fn merge(&mut self, list: GroupList, cx: &mut Context<Self>) {
|
||||||
|
// Carry the event's private section so a later publish can preserve it.
|
||||||
|
self.list
|
||||||
|
.set_content(list.content().map(str::to_owned), list.content_readable());
|
||||||
|
|
||||||
let entries = list.entries().to_vec();
|
let entries = list.entries().to_vec();
|
||||||
|
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
@@ -517,6 +525,58 @@ impl GroupsRegistry {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decrypt a list event's private section into its entries.
|
||||||
|
async fn hydrate_list(
|
||||||
|
mut list: GroupList,
|
||||||
|
signer: &UniversalSigner,
|
||||||
|
me: &PublicKey,
|
||||||
|
) -> GroupList {
|
||||||
|
let Some(content) = list.content().map(str::to_owned) else {
|
||||||
|
return list;
|
||||||
|
};
|
||||||
|
|
||||||
|
match signer.nip44_decrypt(me, &content).await {
|
||||||
|
Ok(plaintext) => match parse_private_section(&plaintext) {
|
||||||
|
Some(entries) => {
|
||||||
|
list.merge_private(entries);
|
||||||
|
list.mark_readable();
|
||||||
|
}
|
||||||
|
None => log::warn!("ignoring a malformed private group list section"),
|
||||||
|
},
|
||||||
|
Err(error) => log::warn!("decrypting the private group list failed: {error}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
list
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Absorb a list event, decrypting its private section off the hot path.
|
||||||
|
fn absorb_list(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||||
|
let list = match GroupList::parse(&event) {
|
||||||
|
Ok(list) => list,
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("ignoring a malformed group list: {error}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if list.content().is_none() {
|
||||||
|
self.merge(list, cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nostr = NostrRegistry::global(cx);
|
||||||
|
let signer = nostr.read(cx).signer();
|
||||||
|
let Some(me) = nostr.read(cx).current_user() else {
|
||||||
|
self.merge(list, cx);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
let list = Self::hydrate_list(list, &signer, &me).await;
|
||||||
|
this.update(cx, |this, cx| this.merge(list, cx)).ok();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
fn watch_forks(&mut self, cx: &mut Context<Self>) {
|
fn watch_forks(&mut self, cx: &mut Context<Self>) {
|
||||||
let executor = cx.background_executor().clone();
|
let executor = cx.background_executor().clone();
|
||||||
let tx = self.signal_tx.clone();
|
let tx = self.signal_tx.clone();
|
||||||
@@ -733,13 +793,14 @@ impl GroupsRegistry {
|
|||||||
.and_then(|entry| entry.name.clone())
|
.and_then(|entry| entry.name.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn upsert_entry(&mut self, key: &GroupKey, name: Option<String>) {
|
fn upsert_entry(&mut self, key: &GroupKey, name: Option<String>, private: bool) {
|
||||||
let name = name.or_else(|| self.entry_name(key));
|
let name = name.or_else(|| self.entry_name(key));
|
||||||
|
|
||||||
self.list.upsert(GroupListEntry {
|
self.list.upsert(GroupListEntry {
|
||||||
id: key.id().clone(),
|
id: key.id().clone(),
|
||||||
relay: key.relay().clone(),
|
relay: key.relay().clone(),
|
||||||
name,
|
name,
|
||||||
|
private,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -761,7 +822,9 @@ impl GroupsRegistry {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.upsert_entry(&key, Some(name));
|
let private = self.list.entry(&key).is_some_and(|entry| entry.private);
|
||||||
|
|
||||||
|
self.upsert_entry(&key, Some(name), private);
|
||||||
self.publish_list(cx);
|
self.publish_list(cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -769,10 +832,30 @@ impl GroupsRegistry {
|
|||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let signer = nostr.read(cx).signer();
|
let signer = nostr.read(cx).signer();
|
||||||
|
let me = nostr.read(cx).current_user();
|
||||||
let builder = self.list.to_builder();
|
let list = self.list.clone();
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |_this, _cx| {
|
self.tasks.push(cx.spawn(async move |_this, _cx| {
|
||||||
|
// Re-encrypt the private section when it carries local entries; a
|
||||||
|
// section this session could not read is carried verbatim.
|
||||||
|
let content = match (list.has_private(), list.content_readable()) {
|
||||||
|
(true, _) => match list.encode_private().zip(me) {
|
||||||
|
Some((plaintext, me)) => match signer.nip44_encrypt(&me, &plaintext).await {
|
||||||
|
Ok(content) => Some(content),
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("encrypting the private group list failed: {error}");
|
||||||
|
list.content().map(str::to_owned)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => None,
|
||||||
|
},
|
||||||
|
(false, false) => list.content().map(str::to_owned),
|
||||||
|
(false, true) => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let builder = EventBuilder::new(Kind::SimpleGroups, content.unwrap_or_default())
|
||||||
|
.tags(list.public_tags());
|
||||||
|
|
||||||
let event = match builder.finalize_async(&signer).await {
|
let event = match builder.finalize_async(&signer).await {
|
||||||
Ok(event) => event,
|
Ok(event) => event,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -929,10 +1012,7 @@ impl GroupsRegistry {
|
|||||||
|
|
||||||
fn handle_event(&mut self, id: SubscriptionId, event: Event, cx: &mut Context<Self>) {
|
fn handle_event(&mut self, id: SubscriptionId, event: Event, cx: &mut Context<Self>) {
|
||||||
if id == list_subscription_id() {
|
if id == list_subscription_id() {
|
||||||
match GroupList::parse(&event) {
|
self.absorb_list(event, cx);
|
||||||
Ok(list) => self.merge(list, cx),
|
|
||||||
Err(error) => log::warn!("ignoring a malformed group list: {error}"),
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+139
-10
@@ -445,6 +445,8 @@ pub struct GroupCreateOptions {
|
|||||||
pub closed: bool,
|
pub closed: bool,
|
||||||
pub restricted: bool,
|
pub restricted: bool,
|
||||||
pub hidden: bool,
|
pub hidden: bool,
|
||||||
|
/// List the group in the NIP-44 encrypted section of the user's group list.
|
||||||
|
pub list_private: bool,
|
||||||
pub custom_id: Option<String>,
|
pub custom_id: Option<String>,
|
||||||
pub parent: Option<GroupId>,
|
pub parent: Option<GroupId>,
|
||||||
}
|
}
|
||||||
@@ -641,10 +643,19 @@ pub(crate) struct GroupListEntry {
|
|||||||
pub id: GroupId,
|
pub id: GroupId,
|
||||||
pub relay: RelayUrl,
|
pub relay: RelayUrl,
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
|
/// Kept in the encrypted section of the list event, not in public tags.
|
||||||
|
pub private: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub(crate) struct GroupList(Vec<GroupListEntry>);
|
pub(crate) struct GroupList {
|
||||||
|
entries: Vec<GroupListEntry>,
|
||||||
|
/// The raw private section of the last seen list event.
|
||||||
|
content: Option<String>,
|
||||||
|
/// Whether the content was decrypted in this session; an opaque section
|
||||||
|
/// (another client's encryption, or a refused decrypt) is carried verbatim.
|
||||||
|
readable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
impl GroupList {
|
impl GroupList {
|
||||||
pub fn parse(event: &Event) -> Result<Self> {
|
pub fn parse(event: &Event) -> Result<Self> {
|
||||||
@@ -672,36 +683,94 @@ impl GroupList {
|
|||||||
id,
|
id,
|
||||||
relay,
|
relay,
|
||||||
name: fields.get(3).cloned(),
|
name: fields.get(3).cloned(),
|
||||||
|
private: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Self(entries))
|
let content = if event.content.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(event.content.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
entries,
|
||||||
|
readable: content.is_none(),
|
||||||
|
content,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn entries(&self) -> &[GroupListEntry] {
|
pub fn entries(&self) -> &[GroupListEntry] {
|
||||||
&self.0
|
&self.entries
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn entry(&self, key: &GroupKey) -> Option<&GroupListEntry> {
|
||||||
|
self.entries
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.id == *key.id() && entry.relay == *key.relay())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn upsert(&mut self, entry: GroupListEntry) {
|
pub fn upsert(&mut self, entry: GroupListEntry) {
|
||||||
match self
|
match self
|
||||||
.0
|
.entries
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
.find(|existing| existing.id == entry.id && existing.relay == entry.relay)
|
.find(|existing| existing.id == entry.id && existing.relay == entry.relay)
|
||||||
{
|
{
|
||||||
Some(existing) => *existing = entry,
|
Some(existing) => *existing = entry,
|
||||||
None => self.0.push(entry),
|
None => self.entries.push(entry),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove(&mut self, key: &GroupKey) {
|
pub fn remove(&mut self, key: &GroupKey) {
|
||||||
self.0
|
self.entries
|
||||||
.retain(|entry| entry.id != *key.id() || entry.relay != *key.relay());
|
.retain(|entry| entry.id != *key.id() || entry.relay != *key.relay());
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_builder(&self) -> EventBuilder {
|
/// The raw private section of the list event, if any.
|
||||||
let mut tags: Vec<Tag> = Vec::with_capacity(self.0.len() * 2);
|
pub(crate) fn content(&self) -> Option<&str> {
|
||||||
|
self.content.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn content_readable(&self) -> bool {
|
||||||
|
self.readable
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_content(&mut self, content: Option<String>, readable: bool) {
|
||||||
|
self.content = content;
|
||||||
|
self.readable = readable;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn has_private(&self) -> bool {
|
||||||
|
self.entries.iter().any(|entry| entry.private)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark the private section as readable after a successful decrypt.
|
||||||
|
pub(crate) fn mark_readable(&mut self) {
|
||||||
|
self.readable = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add decrypted private entries; the public listing wins on collision.
|
||||||
|
pub(crate) fn merge_private(&mut self, entries: Vec<GroupListEntry>) {
|
||||||
|
for entry in entries {
|
||||||
|
let public = self.entries.iter().any(|existing| {
|
||||||
|
!existing.private && existing.id == entry.id && existing.relay == entry.relay
|
||||||
|
});
|
||||||
|
|
||||||
|
if !public {
|
||||||
|
self.upsert(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tags of the public section of the list event.
|
||||||
|
pub(crate) fn public_tags(&self) -> Vec<Tag> {
|
||||||
|
let mut tags = Vec::with_capacity(self.entries.len() * 2);
|
||||||
|
|
||||||
|
for entry in &self.entries {
|
||||||
|
if entry.private {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
for entry in &self.0 {
|
|
||||||
let mut values = vec![
|
let mut values = vec![
|
||||||
entry.id.as_str().to_owned(),
|
entry.id.as_str().to_owned(),
|
||||||
entry.relay.as_str().to_owned(),
|
entry.relay.as_str().to_owned(),
|
||||||
@@ -715,8 +784,68 @@ impl GroupList {
|
|||||||
tags.push(Tag::custom("r", [entry.relay.as_str()]));
|
tags.push(Tag::custom("r", [entry.relay.as_str()]));
|
||||||
}
|
}
|
||||||
|
|
||||||
EventBuilder::new(Kind::SimpleGroups, "").tags(tags)
|
tags
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The private entries encoded as a tag array, ready for encryption.
|
||||||
|
pub(crate) fn encode_private(&self) -> Option<String> {
|
||||||
|
let tags: Vec<Vec<String>> = self
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| entry.private)
|
||||||
|
.map(|entry| {
|
||||||
|
let mut values = vec![
|
||||||
|
entry.id.as_str().to_owned(),
|
||||||
|
entry.relay.as_str().to_owned(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some(name) = &entry.name {
|
||||||
|
values.push(name.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
values
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if tags.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
serde_json::to_string(&tags).ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a decrypted private section; `None` when it isn't a tag array.
|
||||||
|
pub(crate) fn parse_private_section(plaintext: &str) -> Option<Vec<GroupListEntry>> {
|
||||||
|
let tags = serde_json::from_str::<Vec<Vec<String>>>(plaintext).ok()?;
|
||||||
|
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
|
||||||
|
for tag in tags {
|
||||||
|
if tag.first().map(String::as_str) != Some("group") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (Some(id), Some(relay)) = (tag.get(1), tag.get(2)) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(id) = GroupId::new(id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(relay) = RelayUrl::parse(relay) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
entries.push(GroupListEntry {
|
||||||
|
id,
|
||||||
|
relay,
|
||||||
|
name: tag.get(3).cloned(),
|
||||||
|
private: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ pub struct HideGroupMeta {
|
|||||||
pub group: String,
|
pub group: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ask the shell to open the channel creation dialog under a group.
|
||||||
|
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||||
|
#[action(namespace = nip29_ui, no_json)]
|
||||||
|
pub struct CreateChannel {
|
||||||
|
pub relay: RelayUrl,
|
||||||
|
pub parent: String,
|
||||||
|
}
|
||||||
|
|
||||||
const ADMIN_ROLE: &str = "admin";
|
const ADMIN_ROLE: &str = "admin";
|
||||||
const LOAD_OLDER_THRESHOLD: usize = 20;
|
const LOAD_OLDER_THRESHOLD: usize = 20;
|
||||||
|
|
||||||
@@ -96,6 +104,7 @@ pub fn actions(group: WeakEntity<Group>, cx: &App) -> AnyElement {
|
|||||||
let info = group.clone();
|
let info = group.clone();
|
||||||
let edit = group.clone();
|
let edit = group.clone();
|
||||||
let invite = group.clone();
|
let invite = group.clone();
|
||||||
|
let channel = group.clone();
|
||||||
let leave = group;
|
let leave = group;
|
||||||
|
|
||||||
h_flex()
|
h_flex()
|
||||||
@@ -131,6 +140,27 @@ pub fn actions(group: WeakEntity<Group>, cx: &App) -> AnyElement {
|
|||||||
invite::open(invite.clone(), window, cx);
|
invite::open(invite.clone(), window, cx);
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("group-channel")
|
||||||
|
.icon(IconName::Plus)
|
||||||
|
.tooltip("Create channel")
|
||||||
|
.ghost()
|
||||||
|
.small()
|
||||||
|
.on_click(move |_, window, cx| {
|
||||||
|
let Ok(key) = channel.read_with(cx, |group, _cx| group.key().clone())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.dispatch_action(
|
||||||
|
Box::new(CreateChannel {
|
||||||
|
relay: key.relay().clone(),
|
||||||
|
parent: key.id().as_str().to_owned(),
|
||||||
|
}),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.child(
|
.child(
|
||||||
Button::new("leave")
|
Button::new("leave")
|
||||||
@@ -927,7 +957,12 @@ impl Panel for GroupPanel {
|
|||||||
.child(display_name(group))
|
.child(display_name(group))
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| div().text_style(TextStyle::Caption).child("Unknown").into_any_element())
|
.unwrap_or_else(|| {
|
||||||
|
div()
|
||||||
|
.text_style(TextStyle::Caption)
|
||||||
|
.child("Unknown")
|
||||||
|
.into_any_element()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
|
||||||
|
|||||||
@@ -59,6 +59,26 @@ impl UniversalSigner {
|
|||||||
{
|
{
|
||||||
*self.inner.write().expect("RwLock poisoned") = Arc::new(InnerSignerImpl(new_signer));
|
*self.inner.write().expect("RwLock poisoned") = Arc::new(InnerSignerImpl(new_signer));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Encrypt a payload to the given key with NIP-44.
|
||||||
|
pub async fn nip44_encrypt(
|
||||||
|
&self,
|
||||||
|
public_key: &PublicKey,
|
||||||
|
content: &str,
|
||||||
|
) -> Result<String, UniversalSignerError> {
|
||||||
|
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
||||||
|
inner.nip44_encrypt_async(public_key, content).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt a NIP-44 payload from the given key.
|
||||||
|
pub async fn nip44_decrypt(
|
||||||
|
&self,
|
||||||
|
public_key: &PublicKey,
|
||||||
|
payload: &str,
|
||||||
|
) -> Result<String, UniversalSignerError> {
|
||||||
|
let inner = self.inner.read().expect("RwLock poisoned").clone();
|
||||||
|
inner.nip44_decrypt_async(public_key, payload).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
trait InnerSigner: fmt::Debug + Send + Sync + 'static {
|
trait InnerSigner: fmt::Debug + Send + Sync + 'static {
|
||||||
|
|||||||
@@ -23,22 +23,49 @@ const PRIVATE_DESC: &str = "Only members can read group messages";
|
|||||||
const CLOSED_DESC: &str = "Join requests are ignored (invite-only)";
|
const CLOSED_DESC: &str = "Join requests are ignored (invite-only)";
|
||||||
const RESTRICTED_DESC: &str = "Only members can post messages";
|
const RESTRICTED_DESC: &str = "Only members can post messages";
|
||||||
const HIDDEN_DESC: &str = "Hidden from non-members, not discoverable";
|
const HIDDEN_DESC: &str = "Hidden from non-members, not discoverable";
|
||||||
|
const LIST_PRIVATE_DESC: &str = "Kept encrypted in your group list so others can't see you joined";
|
||||||
|
|
||||||
pub fn open(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) {
|
pub fn open(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) {
|
||||||
let view = cx.new(|cx| NewGroup::new(dock, window, cx));
|
open_with(dock, None, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_channel(
|
||||||
|
dock: WeakEntity<DockArea>,
|
||||||
|
parent: GroupKey,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) {
|
||||||
|
open_with(dock, Some(parent), window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_with(
|
||||||
|
dock: WeakEntity<DockArea>,
|
||||||
|
parent: Option<GroupKey>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) {
|
||||||
|
let is_channel = parent.is_some();
|
||||||
|
let view = cx.new(|cx| NewGroup::new(dock, parent, window, cx));
|
||||||
|
|
||||||
window.open_dialog(cx, {
|
window.open_dialog(cx, {
|
||||||
let view = view.clone();
|
let view = view.clone();
|
||||||
|
|
||||||
move |this, _window, _cx| {
|
move |this, _window, _cx| {
|
||||||
this.width(px(450.))
|
let title = if is_channel {
|
||||||
.title("Create a Group")
|
"Create Channel"
|
||||||
.child(view.clone())
|
} else {
|
||||||
|
"Create a Group"
|
||||||
|
};
|
||||||
|
|
||||||
|
this.width(px(440.)).title(title).child(view.clone())
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NewGroup {
|
struct NewGroup {
|
||||||
dock: WeakEntity<DockArea>,
|
dock: WeakEntity<DockArea>,
|
||||||
|
/// The parent group when creating a channel; the relay is locked to its own.
|
||||||
|
parent: Option<GroupKey>,
|
||||||
name_input: Entity<InputState>,
|
name_input: Entity<InputState>,
|
||||||
id_input: Entity<InputState>,
|
id_input: Entity<InputState>,
|
||||||
about_input: Entity<TextareaState>,
|
about_input: Entity<TextareaState>,
|
||||||
@@ -49,6 +76,8 @@ struct NewGroup {
|
|||||||
selected_relay: Option<RelayUrl>,
|
selected_relay: Option<RelayUrl>,
|
||||||
/// Whether the optional custom group id input is shown.
|
/// Whether the optional custom group id input is shown.
|
||||||
show_custom_id: bool,
|
show_custom_id: bool,
|
||||||
|
/// Keep the group in the NIP-44 encrypted section of the user's group list.
|
||||||
|
list_private: bool,
|
||||||
/// The uploaded group picture, ready to be published with the metadata.
|
/// The uploaded group picture, ready to be published with the metadata.
|
||||||
picture: Option<SharedString>,
|
picture: Option<SharedString>,
|
||||||
/// Whether a picture upload is in flight.
|
/// Whether a picture upload is in flight.
|
||||||
@@ -64,7 +93,12 @@ struct NewGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl NewGroup {
|
impl NewGroup {
|
||||||
fn new(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
fn new(
|
||||||
|
dock: WeakEntity<DockArea>,
|
||||||
|
parent: Option<GroupKey>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> Self {
|
||||||
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("#example"));
|
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("#example"));
|
||||||
let id_input = cx.new(|cx| InputState::new(window, cx).placeholder("my-group"));
|
let id_input = cx.new(|cx| InputState::new(window, cx).placeholder("my-group"));
|
||||||
|
|
||||||
@@ -118,13 +152,17 @@ impl NewGroup {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the user's relays once the view is mounted.
|
// Load the user's relays once the view is mounted; a channel's relay
|
||||||
|
// is locked to its parent's, so there is nothing to pick.
|
||||||
|
if parent.is_none() {
|
||||||
cx.defer_in(window, |this, window, cx| {
|
cx.defer_in(window, |this, window, cx| {
|
||||||
this.load_relays(window, cx);
|
this.load_relays(window, cx);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
dock,
|
dock,
|
||||||
|
parent,
|
||||||
name_input,
|
name_input,
|
||||||
id_input,
|
id_input,
|
||||||
about_input,
|
about_input,
|
||||||
@@ -132,6 +170,7 @@ impl NewGroup {
|
|||||||
relays,
|
relays,
|
||||||
selected_relay,
|
selected_relay,
|
||||||
show_custom_id: false,
|
show_custom_id: false,
|
||||||
|
list_private: false,
|
||||||
picture: None,
|
picture: None,
|
||||||
uploading: false,
|
uploading: false,
|
||||||
private: false,
|
private: false,
|
||||||
@@ -226,12 +265,15 @@ impl NewGroup {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let relay = match self.effective_relay(cx) {
|
let relay = match &self.parent {
|
||||||
|
Some(parent) => parent.relay().clone(),
|
||||||
|
None => match self.effective_relay(cx) {
|
||||||
Ok(relay) => relay,
|
Ok(relay) => relay,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
self.set_error(error, cx);
|
self.set_error(error, cx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let custom_id = self.id_input.read(cx).value().trim().to_lowercase();
|
let custom_id = self.id_input.read(cx).value().trim().to_lowercase();
|
||||||
@@ -257,8 +299,9 @@ impl NewGroup {
|
|||||||
closed: self.closed,
|
closed: self.closed,
|
||||||
restricted: self.restricted,
|
restricted: self.restricted,
|
||||||
hidden: self.hidden,
|
hidden: self.hidden,
|
||||||
|
list_private: self.list_private,
|
||||||
custom_id: (!custom_id.is_empty()).then_some(custom_id),
|
custom_id: (!custom_id.is_empty()).then_some(custom_id),
|
||||||
..Default::default()
|
parent: self.parent.as_ref().map(|parent| parent.id().clone()),
|
||||||
};
|
};
|
||||||
|
|
||||||
self.creating = true;
|
self.creating = true;
|
||||||
@@ -347,6 +390,20 @@ impl NewGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_relay(&self, selected: &str, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render_relay(&self, selected: &str, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
// A channel must live on its parent's relay: the parent tag carries a
|
||||||
|
// relay-scoped group id the relay validates against its own state.
|
||||||
|
if let Some(parent) = &self.parent {
|
||||||
|
return v_flex().gap_1().child(field_label("Relay", cx)).child(
|
||||||
|
div()
|
||||||
|
.text_style(TextStyle::Caption)
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(format!(
|
||||||
|
"Created on {} (same relay as the parent group).",
|
||||||
|
relay_label(parent.relay())
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let this = cx.entity().downgrade();
|
let this = cx.entity().downgrade();
|
||||||
let relays = self.relays.clone();
|
let relays = self.relays.clone();
|
||||||
|
|
||||||
@@ -388,73 +445,65 @@ impl NewGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_access(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render_access(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
v_flex()
|
let private = Switch::new("private")
|
||||||
.gap_2()
|
|
||||||
.child(field_label("ACCESS SETTINGS", cx))
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.p_2()
|
|
||||||
.rounded(px(control_radius()))
|
|
||||||
.bg(cx.theme().surface_raised)
|
|
||||||
.child(
|
|
||||||
Switch::new("private")
|
|
||||||
.label("Private")
|
.label("Private")
|
||||||
.description(PRIVATE_DESC)
|
.description(PRIVATE_DESC)
|
||||||
.checked(self.private)
|
.checked(self.private)
|
||||||
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
|
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
|
||||||
this.private = *checked;
|
this.private = *checked;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})),
|
}));
|
||||||
),
|
|
||||||
)
|
let closed = Switch::new("closed")
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.p_2()
|
|
||||||
.rounded(px(control_radius()))
|
|
||||||
.bg(cx.theme().surface_raised)
|
|
||||||
.child(
|
|
||||||
Switch::new("closed")
|
|
||||||
.label("Closed")
|
.label("Closed")
|
||||||
.description(CLOSED_DESC)
|
.description(CLOSED_DESC)
|
||||||
.checked(self.closed)
|
.checked(self.closed)
|
||||||
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
|
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
|
||||||
this.closed = *checked;
|
this.closed = *checked;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})),
|
}));
|
||||||
),
|
|
||||||
)
|
let restricted = Switch::new("restricted")
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.p_2()
|
|
||||||
.rounded(px(control_radius()))
|
|
||||||
.bg(cx.theme().surface_raised)
|
|
||||||
.child(
|
|
||||||
Switch::new("restricted")
|
|
||||||
.label("Restricted")
|
.label("Restricted")
|
||||||
.description(RESTRICTED_DESC)
|
.description(RESTRICTED_DESC)
|
||||||
.checked(self.restricted)
|
.checked(self.restricted)
|
||||||
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
|
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
|
||||||
this.restricted = *checked;
|
this.restricted = *checked;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})),
|
}));
|
||||||
),
|
|
||||||
)
|
let hidden = Switch::new("hidden")
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.p_2()
|
|
||||||
.rounded(px(control_radius()))
|
|
||||||
.bg(cx.theme().surface_raised)
|
|
||||||
.child(
|
|
||||||
Switch::new("hidden")
|
|
||||||
.label("Hidden")
|
.label("Hidden")
|
||||||
.description(HIDDEN_DESC)
|
.description(HIDDEN_DESC)
|
||||||
.checked(self.hidden)
|
.checked(self.hidden)
|
||||||
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
|
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
|
||||||
this.hidden = *checked;
|
this.hidden = *checked;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})),
|
}));
|
||||||
),
|
|
||||||
)
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(field_label("ACCESS SETTINGS", cx))
|
||||||
|
.child(switch_row(private, cx))
|
||||||
|
.child(switch_row(closed, cx))
|
||||||
|
.child(switch_row(restricted, cx))
|
||||||
|
.child(switch_row(hidden, cx))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_list_setting(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let list_private = Switch::new("list-private")
|
||||||
|
.label("Private on my list")
|
||||||
|
.description(LIST_PRIVATE_DESC)
|
||||||
|
.checked(self.list_private)
|
||||||
|
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
|
||||||
|
this.list_private = *checked;
|
||||||
|
cx.notify();
|
||||||
|
}));
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(field_label("YOUR LIST", cx))
|
||||||
|
.child(switch_row(list_private, cx))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_error(
|
fn render_error(
|
||||||
@@ -586,6 +635,7 @@ impl Render for NewGroup {
|
|||||||
.child(self.render_relay(&selected, cx))
|
.child(self.render_relay(&selected, cx))
|
||||||
.child(field("Description", Textarea::new(&self.about_input), cx))
|
.child(field("Description", Textarea::new(&self.about_input), cx))
|
||||||
.child(self.render_access(cx))
|
.child(self.render_access(cx))
|
||||||
|
.child(self.render_list_setting(cx))
|
||||||
.when_some(self.error.clone(), |this, error| {
|
.when_some(self.error.clone(), |this, error| {
|
||||||
this.child(self.render_error(&error, web_url.as_deref(), cx))
|
this.child(self.render_error(&error, web_url.as_deref(), cx))
|
||||||
})
|
})
|
||||||
@@ -601,7 +651,11 @@ impl Render for NewGroup {
|
|||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Button::new("create")
|
Button::new("create")
|
||||||
.label("Create Group")
|
.label(if self.parent.is_some() {
|
||||||
|
"Create Channel"
|
||||||
|
} else {
|
||||||
|
"Create Group"
|
||||||
|
})
|
||||||
.primary()
|
.primary()
|
||||||
.font_semibold()
|
.font_semibold()
|
||||||
.loading(self.creating)
|
.loading(self.creating)
|
||||||
@@ -624,6 +678,15 @@ fn field_label(text: &'static str, cx: &App) -> Div {
|
|||||||
.child(text)
|
.child(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn switch_row(switch: impl IntoElement, cx: &App) -> Div {
|
||||||
|
div()
|
||||||
|
.w_full()
|
||||||
|
.p_2()
|
||||||
|
.rounded(px(control_radius()))
|
||||||
|
.bg(cx.theme().surface_raised)
|
||||||
|
.child(switch)
|
||||||
|
}
|
||||||
|
|
||||||
fn relay_label(relay: &RelayUrl) -> SharedString {
|
fn relay_label(relay: &RelayUrl) -> SharedString {
|
||||||
relay
|
relay
|
||||||
.as_str()
|
.as_str()
|
||||||
|
|||||||
@@ -370,6 +370,25 @@ impl Workspace {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open the channel creation dialog under the action's group.
|
||||||
|
fn on_create_channel(
|
||||||
|
&mut self,
|
||||||
|
action: &nip29_ui::CreateChannel,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let Ok(id) = GroupId::new(action.parent.clone()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
new_group::open_channel(
|
||||||
|
self.dock.downgrade(),
|
||||||
|
GroupKey::new(action.relay.clone(), id),
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Show the sidebar's information for the group a panel belongs to.
|
/// Show the sidebar's information for the group a panel belongs to.
|
||||||
fn show_group_meta(&mut self, action: &nip29_ui::ShowGroupMeta, cx: &mut Context<Self>) {
|
fn show_group_meta(&mut self, action: &nip29_ui::ShowGroupMeta, cx: &mut Context<Self>) {
|
||||||
let Ok(id) = GroupId::new(action.group.clone()) else {
|
let Ok(id) = GroupId::new(action.group.clone()) else {
|
||||||
@@ -453,6 +472,7 @@ impl Render for Workspace {
|
|||||||
div()
|
div()
|
||||||
.id("workspace")
|
.id("workspace")
|
||||||
.on_action(cx.listener(Self::on_command))
|
.on_action(cx.listener(Self::on_command))
|
||||||
|
.on_action(cx.listener(Self::on_create_channel))
|
||||||
.relative()
|
.relative()
|
||||||
.size_full()
|
.size_full()
|
||||||
.child(self.dock.clone())
|
.child(self.dock.clone())
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
# Group creation flow (nostrord parity)
|
|
||||||
|
|
||||||
Plan to bring the group creation flow in line with nostrord
|
|
||||||
(https://github.com/nostrord/nostrord), a NIP-29 client. Analysis of both
|
|
||||||
implementations, then a phased plan.
|
|
||||||
|
|
||||||
## Current state (coop)
|
|
||||||
|
|
||||||
- **Dialog** (`crates/workspace/src/dialogs/new_group.rs`): three plain text
|
|
||||||
inputs (Name, Group id, Relay). Slug is generated from the name when the id
|
|
||||||
is blank. Metadata is `GroupMetadata::default()` with only the name set.
|
|
||||||
Errors surface as toast notifications only.
|
|
||||||
- **Protocol** (`crates/nip29/src/protocol.rs`):
|
|
||||||
`GroupId::create_group` sends kind 9007 with the full metadata in one event.
|
|
||||||
No confirmed-group-id handling and no OK-retry on metadata.
|
|
||||||
- **Registry** (`GroupsRegistry::create` in `crates/nip29/src/lib.rs`): ensures
|
|
||||||
and subscribes the group, upserts the list entry, publishes kind 10009, and
|
|
||||||
on publish failure forgets the group and emits `GroupsEvent::Error`.
|
|
||||||
|
|
||||||
## Reference (nostrord)
|
|
||||||
|
|
||||||
### UI (`CreateGroupModal.kt`)
|
|
||||||
|
|
||||||
- Title "Create a Group" ("Create Channel" for subgroups).
|
|
||||||
- Avatar upload row -> `picture`.
|
|
||||||
- Name (required, placeholder `#example`).
|
|
||||||
- Group ID optional, hidden behind a "Set a custom ID" disclosure; filtered to
|
|
||||||
lowercase `a-z0-9-_`; hint "Leave empty for a random ID. The relay may
|
|
||||||
override your choice."
|
|
||||||
- Relay dropdown: user's relays + current relay + suggested NIP-29 relays +
|
|
||||||
"Custom relay..." revealing a URL field. Locked to the parent's relay for
|
|
||||||
subgroups.
|
|
||||||
- Description, multi-line (3-5 rows) -> `about`.
|
|
||||||
- ACCESS SETTINGS: switch rows with NIP-29 copy — Private (only members can
|
|
||||||
read), Closed (join requests ignored), Restricted (only members can post),
|
|
||||||
Hidden (not discoverable).
|
|
||||||
- YOUR LIST: "Private on my list" — keeps the membership in the NIP-44
|
|
||||||
encrypted section of kind 10009.
|
|
||||||
- Errors: friendly mapping (`blocked:` -> creation must happen on the relay's
|
|
||||||
website, `auth-required` -> authorization required, timeouts, ...) plus an
|
|
||||||
"Open relay website ->" link (wss -> https).
|
|
||||||
- Submit: spinner while in flight, cancelable; validation (name required,
|
|
||||||
valid relay URL).
|
|
||||||
|
|
||||||
### Protocol (`GroupManager.createGroup`)
|
|
||||||
|
|
||||||
Two-step flow:
|
|
||||||
|
|
||||||
1. Group ID: custom (trimmed, lowercased) or a random readable id
|
|
||||||
(`ReadableId.generate()`).
|
|
||||||
2. Kind 9007 (create-group) with only the `h` tag -> await relay OK -> await
|
|
||||||
the relay's kind 39000 to learn the confirmed group id (relays may override
|
|
||||||
the suggested id).
|
|
||||||
3. Kind 9002 (edit-metadata) with `h` + name + access flags (presence-only
|
|
||||||
tags) + about + picture + parent -> await OK (7s timeout, one retry after
|
|
||||||
1s to cover the admin-grant race). If still rejected, keep the group and
|
|
||||||
warn that the relay did not accept its name.
|
|
||||||
4. Bookkeeping: persist membership, discard the self-invite race, set the
|
|
||||||
private-list flag before publishing kind 10009, publish the list, request
|
|
||||||
group messages and metadata.
|
|
||||||
|
|
||||||
### Existing coop infrastructure to reuse
|
|
||||||
|
|
||||||
- `Group::publish` already awaits the relay OK and surfaces the rejection
|
|
||||||
reason (`crates/nip29/src/group.rs`).
|
|
||||||
- `GroupId::edit_metadata` / `Group::edit_metadata` for kind 9002
|
|
||||||
(`crates/nip29/src/protocol.rs`, `group.rs`).
|
|
||||||
- `ui::Switch` (label + description), `PopupMenu` dropdown, `TextareaState`,
|
|
||||||
`Avatar` (`crates/ui`).
|
|
||||||
- Blossom upload via `state::upload` with `AppSettings::get_file_server`.
|
|
||||||
- NIP-65 relay-list extraction, as in `dialogs/relays.rs`
|
|
||||||
(`GossipRelays::load`).
|
|
||||||
- Group panel open flow used by the Browse panel
|
|
||||||
(`crates/workspace/src/panels/browse.rs` `open_panel`).
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
### Phase 1 — Protocol layer (`crates/nip29`)
|
|
||||||
|
|
||||||
- Adopt the two-step creation in `protocol.rs`: `GroupId::create_group` emits
|
|
||||||
kind 9007 with only the `h` tag; metadata moves to the existing
|
|
||||||
`edit_metadata` (kind 9002) builder.
|
|
||||||
- Add `GroupCreateOptions` (name, about, picture, private/closed/restricted/
|
|
||||||
hidden, optional custom id, optional parent) passed to
|
|
||||||
`GroupsRegistry::create`.
|
|
||||||
- Rework `GroupsRegistry::create`:
|
|
||||||
- ID: custom (trim/lowercase/validate) or a random readable id (small port
|
|
||||||
of nostrord's syllable-based `ReadableId`).
|
|
||||||
- `ensure()` -> publish 9007 via `Group::publish` (relay OK) -> wait briefly
|
|
||||||
(with timeout) for the relay's kind 39000 to confirm the group id (the
|
|
||||||
group is already subscribed via `ensure`).
|
|
||||||
- Publish 9002 with the full metadata; retry once after 1s; on persistent
|
|
||||||
failure keep the group and emit a warning event (nostrord parity).
|
|
||||||
- On 9007 failure: `forget()` + emit `GroupsEvent::Error` with the relay's
|
|
||||||
reason.
|
|
||||||
- On success: upsert entry + `publish_list` + emit a new
|
|
||||||
`GroupsEvent::Created(GroupKey)` so the UI can navigate.
|
|
||||||
|
|
||||||
### Phase 2 — New dialog (`crates/workspace/src/dialogs/new_group.rs`)
|
|
||||||
|
|
||||||
Rebuild the modal to nostrord's layout and copy:
|
|
||||||
|
|
||||||
- Header + subtitle; name field; "Set a custom ID" disclosure with filtered
|
|
||||||
input.
|
|
||||||
- Relay dropdown: user's NIP-65 relays (loaded like `GossipRelays::load`) +
|
|
||||||
suggested group relay + "Custom relay..." revealing a URL field with
|
|
||||||
validation.
|
|
||||||
- Description via `TextareaState` (3-5 lines).
|
|
||||||
- ACCESS SETTINGS: four `ui::Switch` rows with nostrord's NIP-29
|
|
||||||
label/description copy.
|
|
||||||
- In-flight state on the Create button (spinner, disabled), inline error row
|
|
||||||
with the friendly error mapping + "Open relay website ->" link (wss ->
|
|
||||||
https).
|
|
||||||
- Subscribe to `GroupsEvent::Created` / `GroupsEvent::Error` while open ->
|
|
||||||
close the dialog and open the new group panel (same flow as
|
|
||||||
`browse.rs::open_panel`).
|
|
||||||
|
|
||||||
### Phase 3 — Avatar upload
|
|
||||||
|
|
||||||
- Picture row using `ui::Avatar` preview + file picker -> `state::upload`
|
|
||||||
(existing blossom upload), wiring upload errors into the dialog's error row.
|
|
||||||
|
|
||||||
### Phase 4 — Optional extensions
|
|
||||||
|
|
||||||
- **"Private on my list"**: requires NIP-44 encrypted-section support in
|
|
||||||
`GroupList` (kind 10009 parse/build/load) — meaningful extra scope; candidate
|
|
||||||
for a follow-up.
|
|
||||||
- **"Create Channel" subgroup mode**: protocol support (parent tag) already
|
|
||||||
exists; needs a trigger point in the group details/subgroups UI and
|
|
||||||
relay-locking in the dialog.
|
|
||||||
|
|
||||||
### Validation
|
|
||||||
|
|
||||||
- `cargo check` / `cargo clippy` after each phase.
|
|
||||||
- Manual run of the dialog against a NIP-29 relay.
|
|
||||||
- Unit tests for the readable-id and input-filter helpers.
|
|
||||||
|
|
||||||
## Open questions
|
|
||||||
|
|
||||||
1. Include "Private on my list" now, or defer the encrypted kind 10009 work?
|
|
||||||
2. Blank id behavior: nostrord generates a random readable id, coop currently
|
|
||||||
slugs the name. Match nostrord?
|
|
||||||
3. Avatar upload in scope for the first pass?
|
|
||||||
4. Subgroup / "Create Channel" mode now, or group creation only?
|
|
||||||
Reference in New Issue
Block a user