Compare commits
3
Commits
4b6f4b3797
...
ea95756637
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea95756637 | ||
|
|
1236f40d83 | ||
|
|
2a07773848 |
+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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,28 +77,28 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
|
||||
.child(
|
||||
Switch::new("private")
|
||||
.label("Private")
|
||||
.description("Only members can read messages.")
|
||||
.description("Only members can read group messages")
|
||||
.checked(current.private)
|
||||
.on_click(toggle(&flags, |flags| &mut flags.private)),
|
||||
)
|
||||
.child(
|
||||
Switch::new("restricted")
|
||||
.label("Restricted")
|
||||
.description("Only members can send messages.")
|
||||
.description("Only members can post messages")
|
||||
.checked(current.restricted)
|
||||
.on_click(toggle(&flags, |flags| &mut flags.restricted)),
|
||||
)
|
||||
.child(
|
||||
Switch::new("hidden")
|
||||
.label("Hidden")
|
||||
.description("Hide the group from non-members.")
|
||||
.description("Hidden from non-members, not discoverable")
|
||||
.checked(current.hidden)
|
||||
.on_click(toggle(&flags, |flags| &mut flags.hidden)),
|
||||
)
|
||||
.child(
|
||||
Switch::new("closed")
|
||||
.label("Closed")
|
||||
.description("Ignore join requests.")
|
||||
.description("Join requests are ignored (invite-only)")
|
||||
.checked(current.closed)
|
||||
.on_click(toggle(&flags, |flags| &mut flags.closed)),
|
||||
)
|
||||
|
||||
@@ -1,40 +1,594 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{App, AppContext, ClickEvent, Div, Entity, ParentElement, Styled, Window, px};
|
||||
use nip29::{GroupId, GroupKey, GroupMetadata, GroupsRegistry};
|
||||
use anyhow::Error;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
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 theme::{ActiveTheme, TextStyle, Typeset as _};
|
||||
use ui::button::{Button, ButtonVariants, IconPosition};
|
||||
use ui::input::{Input, InputState};
|
||||
use ui::notification::Notification;
|
||||
use ui::{IconName, StyledExt, WindowExtension, h_flex, v_flex};
|
||||
use settings::AppSettings;
|
||||
use state::{NostrRegistry, upload};
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _, control_radius};
|
||||
use ui::avatar::Avatar;
|
||||
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";
|
||||
|
||||
pub fn open(window: &mut Window, cx: &mut App) {
|
||||
let name = cx.new(|cx| InputState::new(window, cx).placeholder("Group name"));
|
||||
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"));
|
||||
pub fn open(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) {
|
||||
let view = cx.new(|cx| NewGroup::new(dock, window, cx));
|
||||
|
||||
let submit: SubmitFn = {
|
||||
let (name, id, relay) = (name.clone(), id.clone(), relay.clone());
|
||||
Rc::new(move |_, window, cx| create(&name, &id, &relay, window, cx))
|
||||
};
|
||||
window.open_dialog(cx, {
|
||||
let view = view.clone();
|
||||
move |this, _window, _cx| {
|
||||
this.width(px(450.))
|
||||
.title("Create a Group")
|
||||
.child(view.clone())
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.open_dialog(cx, move |this, _window, cx| {
|
||||
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>,
|
||||
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,
|
||||
/// 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>, 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(
|
||||
®istry,
|
||||
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.
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.load_relays(window, cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
dock,
|
||||
name_input,
|
||||
id_input,
|
||||
about_input,
|
||||
custom_relay_input,
|
||||
relays,
|
||||
selected_relay,
|
||||
show_custom_id: 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.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,
|
||||
custom_id: (!custom_id.is_empty()).then_some(custom_id),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
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 {
|
||||
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(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(field("Name", &name, cx))
|
||||
.child(field("Group id", &id, cx))
|
||||
.child(field("Relay", &relay, cx)),
|
||||
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 {
|
||||
v_flex()
|
||||
.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")
|
||||
.description(PRIVATE_DESC)
|
||||
.checked(self.private)
|
||||
.on_click(cx.listener(|this, checked: &bool, _window, cx| {
|
||||
this.private = *checked;
|
||||
cx.notify();
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.p_2()
|
||||
.rounded(px(control_radius()))
|
||||
.bg(cx.theme().surface_raised)
|
||||
.child(
|
||||
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();
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.p_2()
|
||||
.rounded(px(control_radius()))
|
||||
.bg(cx.theme().surface_raised)
|
||||
.child(
|
||||
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();
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.p_2()
|
||||
.rounded(px(control_radius()))
|
||||
.bg(cx.theme().surface_raised)
|
||||
.child(
|
||||
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();
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.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))
|
||||
.when_some(self.error.clone(), |this, error| {
|
||||
this.child(self.render_error(&error, web_url.as_deref(), cx))
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
@@ -47,95 +601,76 @@ pub fn open(window: &mut Window, cx: &mut App) {
|
||||
)
|
||||
.child(
|
||||
Button::new("create")
|
||||
.icon(IconName::ArrowRight)
|
||||
.icon_position(IconPosition::End)
|
||||
.label("Create")
|
||||
.label("Create Group")
|
||||
.primary()
|
||||
.font_semibold()
|
||||
.on_click({
|
||||
let submit = submit.clone();
|
||||
move |event, window, cx| {
|
||||
if submit(event, window, cx) {
|
||||
window.close_dialog(cx);
|
||||
}
|
||||
}
|
||||
}),
|
||||
.loading(self.creating)
|
||||
.disabled(self.creating || self.uploading)
|
||||
.on_click(cx.listener(|this, _event, _window, cx| this.create(cx))),
|
||||
),
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn field(label: &'static str, input: &Entity<InputState>, cx: &App) -> Div {
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.text_style(TextStyle::Callout)
|
||||
fn field(text: &'static str, input: impl IntoElement, cx: &App) -> Div {
|
||||
v_flex().gap_1().child(field_label(text, cx)).child(input)
|
||||
}
|
||||
|
||||
fn field_label(text: &'static str, cx: &App) -> Div {
|
||||
div()
|
||||
.text_style(TextStyle::Caption)
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child(label)
|
||||
.child(Input::new(input))
|
||||
.font_semibold()
|
||||
.child(text)
|
||||
}
|
||||
|
||||
fn create(
|
||||
name: &Entity<InputState>,
|
||||
id: &Entity<InputState>,
|
||||
relay: &Entity<InputState>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> bool {
|
||||
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();
|
||||
fn relay_label(relay: &RelayUrl) -> SharedString {
|
||||
relay
|
||||
.as_str()
|
||||
.trim_start_matches("wss://")
|
||||
.trim_start_matches("ws://")
|
||||
.to_owned()
|
||||
.into()
|
||||
}
|
||||
|
||||
if name_value.is_empty() {
|
||||
window.push_notification(Notification::error("Enter a group name"), cx);
|
||||
return false;
|
||||
}
|
||||
fn relay_web_url(relay: &RelayUrl) -> String {
|
||||
relay
|
||||
.as_str()
|
||||
.replacen("wss://", "https://", 1)
|
||||
.replacen("ws://", "http://", 1)
|
||||
}
|
||||
|
||||
let id = if raw_id.is_empty() {
|
||||
slug(&name_value)
|
||||
/// 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 {
|
||||
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) => {
|
||||
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;
|
||||
}
|
||||
raw.into()
|
||||
}
|
||||
|
||||
slug.trim_end_matches('-').to_owned()
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use nostr_sdk::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
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::dock::{self, DockArea, DockLayout, DockPlacement, Panel, PanelHandle};
|
||||
use ui::notification::{Notification, NotificationKind};
|
||||
@@ -298,7 +298,7 @@ impl Workspace {
|
||||
new_chat::open(self.dock.downgrade(), window, cx);
|
||||
}
|
||||
Command::NewGroup => {
|
||||
new_group::open(window, cx);
|
||||
new_group::open(self.dock.downgrade(), window, cx);
|
||||
}
|
||||
Command::JoinGroup => {
|
||||
join_group::open(window, cx);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# 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