feat: add relay group creation #62

Merged
reya merged 4 commits from group-creation into master 2026-10-10 14:11:19 +00:00
9 changed files with 1179 additions and 149 deletions
+174 -23
View File
@@ -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;
@@ -23,6 +23,10 @@ pub(crate) static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
const BROWSE_LIMIT: usize = 500; const BROWSE_LIMIT: usize = 500;
const BROWSE_TIMEOUT: Duration = Duration::from_secs(10); const BROWSE_TIMEOUT: Duration = Duration::from_secs(10);
const FORK_INTERVAL: Duration = Duration::from_secs(15 * 60); const FORK_INTERVAL: Duration = Duration::from_secs(15 * 60);
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) { pub fn init(cx: &mut App) {
GroupsRegistry::set_global(cx.new(GroupsRegistry::new), cx); GroupsRegistry::set_global(cx.new(GroupsRegistry::new), cx);
@@ -46,6 +50,11 @@ pub enum GroupsEvent {
}, },
/// The discovery synced new groups into the local database. /// The discovery synced new groups into the local database.
Synced, Synced,
/// A relay accepted a new group; the warning carries a non-fatal issue.
Created {
key: GroupKey,
warning: Option<String>,
},
} }
#[derive(Debug)] #[derive(Debug)]
@@ -161,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);
@@ -191,31 +200,94 @@ 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); self.ensure(key.clone(), cx);
let Some(group) = self.group(&key, cx) else { let Some(group) = self.group(&key, cx) else {
return; return;
}; };
self.upsert_entry(&key, metadata.name().map(str::to_owned)); let metadata = options.metadata(name.clone());
self.publish_list(cx); let list_private = options.list_private;
let builder = key.id().create_group(None);
cx.emit(GroupsEvent::Updated);
cx.notify();
let builder = key.id().create_group(&metadata, None);
let published = group.read(cx).publish(builder, cx); let published = group.read(cx).publish(builder, cx);
let logged = key.clone(); let created = key;
self.tasks.push(cx.spawn(async move |this, cx| { 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.update(cx, |this, cx| {
this.forget(logged, cx); this.forget(created, cx);
cx.emit(GroupsEvent::Error(e.to_string())); cx.emit(GroupsEvent::Error(error.to_string()));
}) })
.ok(); .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), list_private);
this.publish_list(cx);
cx.emit(GroupsEvent::Updated);
cx.emit(GroupsEvent::Created {
key: created,
warning,
});
cx.notify();
})
.ok();
})); }));
} }
@@ -392,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()),
} }
}); });
@@ -435,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 {
@@ -446,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();
@@ -662,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,
}); });
} }
@@ -690,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);
} }
@@ -698,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) => {
@@ -858,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;
} }
+171 -12
View File
@@ -155,9 +155,9 @@ impl GroupId {
.tag_maybe(previous) .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, "") EventBuilder::new(Kind::GroupCreateGroup, "")
.tags(self.metadata_tags(metadata)) .tag(self.h_tag())
.tag_maybe(previous) .tag_maybe(previous)
} }
@@ -435,6 +435,38 @@ 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,
/// 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 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)] #[derive(Debug, Clone)]
pub struct GroupCandidate { pub struct GroupCandidate {
pub key: GroupKey, pub key: GroupKey,
@@ -611,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> {
@@ -642,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(),
@@ -685,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)]
+41
View File
@@ -58,3 +58,44 @@ pub(crate) fn parse_members(event: &Event) -> Option<(GroupId, Timestamp, usize)
Some((id, event.created_at, members)) 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()));
}
}
}
+36 -1
View File
@@ -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> {
+4 -4
View File
@@ -77,28 +77,28 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
.child( .child(
Switch::new("private") Switch::new("private")
.label("Private") .label("Private")
.description("Only members can read messages.") .description("Only members can read group messages")
.checked(current.private) .checked(current.private)
.on_click(toggle(&flags, |flags| &mut flags.private)), .on_click(toggle(&flags, |flags| &mut flags.private)),
) )
.child( .child(
Switch::new("restricted") Switch::new("restricted")
.label("Restricted") .label("Restricted")
.description("Only members can send messages.") .description("Only members can post messages")
.checked(current.restricted) .checked(current.restricted)
.on_click(toggle(&flags, |flags| &mut flags.restricted)), .on_click(toggle(&flags, |flags| &mut flags.restricted)),
) )
.child( .child(
Switch::new("hidden") Switch::new("hidden")
.label("Hidden") .label("Hidden")
.description("Hide the group from non-members.") .description("Hidden from non-members, not discoverable")
.checked(current.hidden) .checked(current.hidden)
.on_click(toggle(&flags, |flags| &mut flags.hidden)), .on_click(toggle(&flags, |flags| &mut flags.hidden)),
) )
.child( .child(
Switch::new("closed") Switch::new("closed")
.label("Closed") .label("Closed")
.description("Ignore join requests.") .description("Join requests are ignored (invite-only)")
.checked(current.closed) .checked(current.closed)
.on_click(toggle(&flags, |flags| &mut flags.closed)), .on_click(toggle(&flags, |flags| &mut flags.closed)),
) )
+20
View File
@@ -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 {
+702 -104
View File
@@ -1,40 +1,644 @@
use std::rc::Rc; use anyhow::Error;
use gpui::prelude::FluentBuilder;
use gpui::{App, AppContext, ClickEvent, Div, Entity, ParentElement, Styled, Window, px}; use gpui::{
use nip29::{GroupId, GroupKey, GroupMetadata, GroupsRegistry}; Anchor, App, AppContext, Context, Div, Entity, InteractiveElement as _, IntoElement,
ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement as _,
Styled, Subscription, Task, WeakEntity, Window, div, px,
};
use nip29::{GroupCreateOptions, GroupKey, GroupsEvent, GroupsRegistry};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use theme::{ActiveTheme, TextStyle, Typeset as _}; use settings::AppSettings;
use ui::button::{Button, ButtonVariants, IconPosition}; use state::{NostrRegistry, upload};
use ui::input::{Input, InputState}; use theme::{ActiveTheme, TextStyle, Typeset as _, control_radius};
use ui::notification::Notification; use ui::avatar::Avatar;
use ui::{IconName, StyledExt, WindowExtension, h_flex, v_flex}; use ui::button::{Button, ButtonVariants};
use ui::dock::{DockArea, DockPlacement, PanelHandle};
use ui::input::{Input, InputEvent, InputState, Textarea, TextareaState};
use ui::menu::{DropdownMenu, PopupMenuItem};
use ui::switch::Switch;
use ui::{Disableable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
type SubmitFn = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) -> bool>; const SUGGESTED_RELAY: &str = "wss://relay.ditto.pub";
const PRIVATE_DESC: &str = "Only members can read group messages";
const CLOSED_DESC: &str = "Join requests are ignored (invite-only)";
const RESTRICTED_DESC: &str = "Only members can post messages";
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(window: &mut Window, cx: &mut App) { pub fn open(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) {
let name = cx.new(|cx| InputState::new(window, cx).placeholder("Group name")); open_with(dock, None, window, cx);
let id = cx.new(|cx| InputState::new(window, cx).placeholder("Group id")); }
let relay = cx.new(|cx| InputState::new(window, cx).placeholder("wss://relay.example.com"));
let submit: SubmitFn = { pub fn open_channel(
let (name, id, relay) = (name.clone(), id.clone(), relay.clone()); dock: WeakEntity<DockArea>,
Rc::new(move |_, window, cx| create(&name, &id, &relay, window, cx)) 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, {
let view = view.clone();
move |this, _window, _cx| {
let title = if is_channel {
"Create Channel"
} else {
"Create a Group"
}; };
window.open_dialog(cx, move |this, _window, cx| { this.width(px(440.)).title(title).child(view.clone())
this.width(px(380.)) }
.title("New group") });
.on_ok({ }
let submit = submit.clone();
move |event, window, cx| submit(event, window, cx) struct NewGroup {
dock: WeakEntity<DockArea>,
/// The parent group when creating a channel; the relay is locked to its own.
parent: Option<GroupKey>,
name_input: Entity<InputState>,
id_input: Entity<InputState>,
about_input: Entity<TextareaState>,
custom_relay_input: Entity<InputState>,
/// The relays offered in the dropdown.
relays: Vec<RelayUrl>,
/// The selected relay; `None` selects the custom relay field.
selected_relay: Option<RelayUrl>,
/// Whether the optional custom group id input is shown.
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.
picture: Option<SharedString>,
/// Whether a picture upload is in flight.
uploading: bool,
private: bool,
closed: bool,
restricted: bool,
hidden: bool,
creating: bool,
error: Option<SharedString>,
tasks: Vec<Task<Result<(), Error>>>,
_subscriptions: Vec<Subscription>,
}
impl NewGroup {
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 id_input = cx.new(|cx| InputState::new(window, cx).placeholder("my-group"));
let custom_relay_input =
cx.new(|cx| InputState::new(window, cx).placeholder("relay.example.com"));
let about_input = cx.new(|cx| {
TextareaState::new(window, cx)
.auto_grow(3, 5)
.placeholder("What is this group about?")
});
let relays = match RelayUrl::parse(SUGGESTED_RELAY) {
Ok(relay) => vec![relay],
Err(error) => {
log::warn!("the suggested group relay is invalid: {error}");
Vec::new()
}
};
let selected_relay = relays.first().cloned();
let registry = GroupsRegistry::global(cx);
let mut subscriptions = vec![];
subscriptions.push(cx.subscribe_in(
&registry,
window,
|this, _registry, event, window, cx| match event {
GroupsEvent::Created { key, .. } => {
this.creating = false;
this.open_group(key.clone(), window, cx);
}
GroupsEvent::Error(error) if this.creating => {
this.creating = false;
this.set_error(friendly_error(error), cx);
}
_ => {}
},
));
// Clear stale errors as the user edits the failing fields.
for input in [&name_input, &custom_relay_input] {
subscriptions.push(cx.subscribe_in(
input,
window,
|this, _input, event, _window, cx| {
if let InputEvent::Change = event {
this.clear_error(cx);
}
},
));
}
// 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| {
this.load_relays(window, cx);
});
}
Self {
dock,
parent,
name_input,
id_input,
about_input,
custom_relay_input,
relays,
selected_relay,
show_custom_id: false,
list_private: false,
picture: None,
uploading: false,
private: false,
closed: false,
restricted: false,
hidden: false,
creating: false,
error: None,
tasks: vec![],
_subscriptions: subscriptions,
}
}
/// Load the signed-in user's relay list.
fn load_relays(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let Some(public_key) = nostr.read(cx).current_user() else {
return;
};
let task: Task<Result<Vec<RelayUrl>, Error>> = cx.background_spawn(async move {
let filter = Filter::new()
.kind(Kind::RelayList)
.author(public_key)
.limit(1);
if let Some(event) = client.database().query(filter).await?.into_iter().next() {
Ok(nip65::extract_relay_list(&event)
.map(|(url, _)| url)
.collect())
} else {
Ok(Vec::new())
}
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let relays = task.await?;
this.update(cx, |this, cx| {
if !relays.is_empty() {
this.relays = relays;
}
this.selected_relay = this
.selected_relay
.take()
.filter(|selected| this.relays.contains(selected))
.or_else(|| this.relays.first().cloned());
cx.notify();
})?;
Ok(())
}));
}
/// The relay the group will be created on.
fn effective_relay(&self, cx: &App) -> Result<RelayUrl, SharedString> {
let Some(selected) = &self.selected_relay else {
let raw = self.custom_relay_input.read(cx).value().trim().to_owned();
if raw.is_empty() {
return Err("Enter a relay to create the group on.".into());
}
let url = if raw.starts_with("wss://") || raw.starts_with("ws://") {
raw
} else {
format!("wss://{raw}")
};
return RelayUrl::parse(&url).map_err(|_| {
SharedString::from("Enter a valid relay URL (e.g. relay.example.com).")
});
};
Ok(selected.clone())
}
fn create(&mut self, cx: &mut Context<Self>) {
if self.creating || self.uploading {
return;
}
let registry = GroupsRegistry::global(cx);
let name = self.name_input.read(cx).value().trim().to_owned();
if name.is_empty() {
self.set_error("Group name is required.", cx);
return;
}
let relay = match &self.parent {
Some(parent) => parent.relay().clone(),
None => match self.effective_relay(cx) {
Ok(relay) => relay,
Err(error) => {
self.set_error(error, cx);
return;
}
},
};
let custom_id = self.id_input.read(cx).value().trim().to_lowercase();
if !custom_id.is_empty()
&& !custom_id
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
{
self.set_error(
"The group id can only contain letters, numbers, '-' and '_'.",
cx,
);
return;
}
let about = self.about_input.read(cx).value().trim().to_owned();
let options = GroupCreateOptions {
name,
about: (!about.is_empty()).then_some(about),
picture: self.picture.as_ref().map(|picture| picture.to_string()),
private: self.private,
closed: self.closed,
restricted: self.restricted,
hidden: self.hidden,
list_private: self.list_private,
custom_id: (!custom_id.is_empty()).then_some(custom_id),
parent: self.parent.as_ref().map(|parent| parent.id().clone()),
};
self.creating = true;
self.error = None;
cx.notify();
registry.update(cx, |registry, cx| {
registry.create(relay, options, cx);
});
}
fn open_group(&self, key: GroupKey, window: &mut Window, cx: &mut App) {
let registry = GroupsRegistry::global(cx);
let group = registry.update(cx, |registry, cx| registry.open(key, cx));
ui::dock::add_panel_to(
&self.dock,
PanelHandle::new(nip29_ui::init(group, window, cx)),
DockPlacement::Center,
window,
cx,
);
window.close_dialog(cx);
}
fn select_relay(&mut self, relay: Option<RelayUrl>, cx: &mut Context<Self>) {
self.selected_relay = relay;
self.error = None;
cx.notify();
}
fn set_uploading(&mut self, uploading: bool, cx: &mut Context<Self>) {
self.uploading = uploading;
cx.notify();
}
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let server = AppSettings::get_file_server(cx);
let path = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: None,
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
// Selecting no file means the prompt was cancelled
let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else {
return Ok(());
};
this.update(cx, |this, cx| {
this.set_uploading(true, cx);
})?;
match upload(server, path, cx).await {
Ok(url) => {
this.update(cx, |this, cx| {
this.picture = Some(url.to_string().into());
this.set_uploading(false, cx);
})?;
}
Err(error) => {
this.update(cx, |this, cx| {
this.set_uploading(false, cx);
this.set_error(friendly_error(&error.to_string()), cx);
})?;
}
}
Ok(())
}));
}
fn set_error(&mut self, message: impl Into<SharedString>, cx: &mut Context<Self>) {
self.error = Some(message.into());
cx.notify();
}
fn clear_error(&mut self, cx: &mut Context<Self>) {
if self.error.take().is_some() {
cx.notify();
}
}
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 relays = self.relays.clone();
v_flex()
.gap_1()
.child(field_label("Relay", cx))
.when(self.selected_relay.is_none(), |this| {
this.child(Input::new(&self.custom_relay_input))
}) })
.child( .child(
Button::new("relay")
.label(selected.to_owned())
.ghost_alt()
.caret()
.dropdown_menu_with_anchor(Anchor::BottomLeft, move |menu, _window, _cx| {
let menu = relays.iter().fold(menu, |menu, relay| {
let this = this.clone();
let relay = relay.clone();
menu.item(PopupMenuItem::new(relay_label(&relay)).on_click(
move |_, _window, cx| {
this.update(cx, |this, cx| {
this.select_relay(Some(relay.clone()), cx);
})
.ok();
},
))
});
let this = this.clone();
menu.item(PopupMenuItem::new("Custom relay…").on_click(
move |_, _window, cx| {
this.update(cx, |this, cx| this.select_relay(None, cx)).ok();
},
))
}),
)
}
fn render_access(&self, cx: &mut Context<Self>) -> impl IntoElement {
let private = Switch::new("private")
.label("Private")
.description(PRIVATE_DESC)
.checked(self.private)
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
this.private = *checked;
cx.notify();
}));
let closed = Switch::new("closed")
.label("Closed")
.description(CLOSED_DESC)
.checked(self.closed)
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
this.closed = *checked;
cx.notify();
}));
let restricted = Switch::new("restricted")
.label("Restricted")
.description(RESTRICTED_DESC)
.checked(self.restricted)
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
this.restricted = *checked;
cx.notify();
}));
let hidden = Switch::new("hidden")
.label("Hidden")
.description(HIDDEN_DESC)
.checked(self.hidden)
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
this.hidden = *checked;
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(
&self,
error: &str,
web_url: Option<&str>,
cx: &Context<Self>,
) -> impl IntoElement {
h_flex()
.items_start()
.gap_2()
.child(
div()
.flex_1()
.text_style(TextStyle::Caption)
.text_color(cx.theme().danger)
.child(error.to_owned()),
)
.when(shows_relay_website(error), |this| {
this.child(
div()
.id("relay-website")
.cursor_pointer()
.flex_shrink_0()
.underline()
.text_style(TextStyle::Caption)
.text_color(cx.theme().danger)
.child("Open relay website →")
.on_click({
let web_url = web_url.map(str::to_owned);
move |_, _window, cx| {
if let Some(url) = &web_url {
cx.open_url(url);
}
}
}),
)
})
}
}
impl Render for NewGroup {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let selected = self
.selected_relay
.as_ref()
.map(relay_label)
.unwrap_or_else(|| "Custom relay…".into());
let web_url = self
.effective_relay(cx)
.ok()
.map(|relay| relay_web_url(&relay));
// The avatar fallback is seeded with the id, falling back to the name.
let id = self.id_input.read(cx).value().trim().to_lowercase();
let name = self.name_input.read(cx).value().trim().to_owned();
let seed: String = if !id.is_empty() {
id
} else if !name.is_empty() {
name
} else {
"group".to_owned()
};
v_flex() v_flex()
.gap_3() .gap_3()
.child(field("Name", &name, cx)) .child(
.child(field("Group id", &id, cx)) div()
.child(field("Relay", &relay, cx)), .text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted)
.child(
"Give your new group a name and description. You can always change these later.",
),
) )
.child(
h_flex()
.items_center()
.gap_3()
.child(Avatar::new(self.picture.clone()).seed(seed).large())
.child(
Button::new("upload-picture")
.label(if self.picture.is_some() {
"Change picture"
} else {
"Add a picture"
})
.xsmall()
.ghost()
.disabled(self.uploading)
.loading(self.uploading)
.on_click(cx.listener(|this, _event, window, cx| {
this.upload(window, cx);
})),
),
)
.child(field("Group Name", Input::new(&self.name_input), cx))
.when(self.show_custom_id, |this| {
this.child(
v_flex()
.gap_1()
.child(field("Group ID (optional)", Input::new(&self.id_input), cx))
.child(
div()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_faint)
.child(
"Leave empty for a random ID. The relay may override your choice.",
),
),
)
})
.when(!self.show_custom_id, |this| {
this.child(
div()
.id("show-custom-id")
.cursor_pointer()
.underline()
.text_style(TextStyle::Caption)
.text_color(cx.theme().accent)
.child("Set a custom ID")
.on_click(cx.listener(|this, _event, _window, cx| {
this.show_custom_id = true;
cx.notify();
})),
)
})
.child(self.render_relay(&selected, cx))
.child(field("Description", Textarea::new(&self.about_input), cx))
.child(self.render_access(cx))
.child(self.render_list_setting(cx))
.when_some(self.error.clone(), |this, error| {
this.child(self.render_error(&error, web_url.as_deref(), cx))
})
.child( .child(
h_flex() h_flex()
.gap_1() .gap_1()
@@ -47,95 +651,89 @@ pub fn open(window: &mut Window, cx: &mut App) {
) )
.child( .child(
Button::new("create") Button::new("create")
.icon(IconName::ArrowRight) .label(if self.parent.is_some() {
.icon_position(IconPosition::End) "Create Channel"
.label("Create") } else {
"Create Group"
})
.primary() .primary()
.font_semibold() .font_semibold()
.on_click({ .loading(self.creating)
let submit = submit.clone(); .disabled(self.creating || self.uploading)
move |event, window, cx| { .on_click(cx.listener(|this, _event, _window, cx| this.create(cx))),
if submit(event, window, cx) {
window.close_dialog(cx);
}
}
}),
), ),
) )
}); }
} }
fn field(label: &'static str, input: &Entity<InputState>, cx: &App) -> Div { fn field(text: &'static str, input: impl IntoElement, cx: &App) -> Div {
v_flex() v_flex().gap_1().child(field_label(text, cx)).child(input)
.gap_1() }
.text_style(TextStyle::Callout)
fn field_label(text: &'static str, cx: &App) -> Div {
div()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted) .text_color(cx.theme().text_muted)
.child(label) .font_semibold()
.child(Input::new(input)) .child(text)
} }
fn create( fn switch_row(switch: impl IntoElement, cx: &App) -> Div {
name: &Entity<InputState>, div()
id: &Entity<InputState>, .w_full()
relay: &Entity<InputState>, .p_2()
window: &mut Window, .rounded(px(control_radius()))
cx: &mut App, .bg(cx.theme().surface_raised)
) -> bool { .child(switch)
let name_value = name.read(cx).value().trim().to_owned();
let raw_id = id.read(cx).value().trim().to_owned();
let raw_relay = relay.read(cx).value().trim().to_owned();
if name_value.is_empty() {
window.push_notification(Notification::error("Enter a group name"), cx);
return false;
} }
let id = if raw_id.is_empty() { fn relay_label(relay: &RelayUrl) -> SharedString {
slug(&name_value) relay
.as_str()
.trim_start_matches("wss://")
.trim_start_matches("ws://")
.to_owned()
.into()
}
fn relay_web_url(relay: &RelayUrl) -> String {
relay
.as_str()
.replacen("wss://", "https://", 1)
.replacen("ws://", "http://", 1)
}
/// Whether the relay's website likely resolves the rejection.
fn shows_relay_website(error: &str) -> bool {
let error = error.to_lowercase();
[
"website",
"not allowed",
"restricted",
"authorization",
"auth-required",
"blocked",
]
.iter()
.any(|hint| error.contains(hint))
}
/// Map raw relay rejection reasons to user-facing copy (nostrord parity).
fn friendly_error(raw: &str) -> SharedString {
let lowered = raw.to_lowercase();
if lowered.contains("blocked:") {
"Group creation on this relay must be done via the relay's website.".into()
} else if lowered.contains("auth-required")
|| lowered.contains("not allowed")
|| lowered.contains("restricted")
{
"This relay requires authorization to create groups.".into()
} else if lowered.contains("did not respond") || lowered.contains("timeout") {
"Relay did not respond. Try again.".into()
} else if lowered.contains("not connected") {
"Not connected to relay. Try again.".into()
} else { } else {
raw_id raw.into()
};
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) => {
window.push_notification(Notification::error(error.to_string()), cx);
return false;
}
};
let mut metadata = GroupMetadata::default();
metadata.name = Some(name_value);
let registry = GroupsRegistry::global(cx);
registry.update(cx, |registry, cx| {
registry.create(GroupKey::new(relay, id), metadata, 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()
}
+22 -2
View File
@@ -14,7 +14,7 @@ use nostr_sdk::prelude::*;
use serde::Deserialize; use serde::Deserialize;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::{NostrRegistry, StateEvent}; use state::{NostrRegistry, StateEvent};
use theme::{ActiveTheme, TextStyle, Typeset as _, SIDEBAR_WIDTH, Theme}; use theme::{ActiveTheme, SIDEBAR_WIDTH, TextStyle, Theme, Typeset as _};
use ui::button::Button; use ui::button::Button;
use ui::dock::{self, DockArea, DockLayout, DockPlacement, Panel, PanelHandle}; use ui::dock::{self, DockArea, DockLayout, DockPlacement, Panel, PanelHandle};
use ui::notification::{Notification, NotificationKind}; use ui::notification::{Notification, NotificationKind};
@@ -298,7 +298,7 @@ impl Workspace {
new_chat::open(self.dock.downgrade(), window, cx); new_chat::open(self.dock.downgrade(), window, cx);
} }
Command::NewGroup => { Command::NewGroup => {
new_group::open(window, cx); new_group::open(self.dock.downgrade(), window, cx);
} }
Command::JoinGroup => { Command::JoinGroup => {
join_group::open(window, cx); join_group::open(window, cx);
@@ -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())
+6
View File
@@ -127,6 +127,12 @@ impl Sidebar {
GroupsEvent::Error(error) => { GroupsEvent::Error(error) => {
window.push_notification(Notification::error(error.clone()), cx); window.push_notification(Notification::error(error.clone()), cx);
} }
GroupsEvent::Created {
warning: Some(warning),
..
} => {
window.push_notification(Notification::warning(warning.clone()), cx);
}
_ => {} _ => {}
}; };
}), }),