add encrypted private section to group lists and channels

This commit is contained in:
2026-10-10 21:07:25 +07:00
parent ea95756637
commit f8a987003d
7 changed files with 451 additions and 248 deletions
+144 -81
View File
@@ -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 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(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, {
let view = view.clone();
move |this, _window, _cx| {
this.width(px(450.))
.title("Create a Group")
.child(view.clone())
let title = if is_channel {
"Create Channel"
} else {
"Create a Group"
};
this.width(px(440.)).title(title).child(view.clone())
}
});
}
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>,
@@ -49,6 +76,8 @@ struct NewGroup {
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.
@@ -64,7 +93,12 @@ struct 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 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.
cx.defer_in(window, |this, window, cx| {
this.load_relays(window, 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,
@@ -132,6 +170,7 @@ impl NewGroup {
relays,
selected_relay,
show_custom_id: false,
list_private: false,
picture: None,
uploading: false,
private: false,
@@ -226,12 +265,15 @@ impl NewGroup {
return;
}
let relay = match self.effective_relay(cx) {
Ok(relay) => relay,
Err(error) => {
self.set_error(error, 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();
@@ -257,8 +299,9 @@ impl NewGroup {
closed: self.closed,
restricted: self.restricted,
hidden: self.hidden,
list_private: self.list_private,
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;
@@ -347,6 +390,20 @@ impl NewGroup {
}
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();
@@ -388,73 +445,65 @@ impl NewGroup {
}
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(
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();
})),
),
)
.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(
@@ -586,6 +635,7 @@ impl Render for NewGroup {
.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))
})
@@ -601,7 +651,11 @@ impl Render for NewGroup {
)
.child(
Button::new("create")
.label("Create Group")
.label(if self.parent.is_some() {
"Create Channel"
} else {
"Create Group"
})
.primary()
.font_semibold()
.loading(self.creating)
@@ -624,6 +678,15 @@ fn field_label(text: &'static str, cx: &App) -> Div {
.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 {
relay
.as_str()
+20
View File
@@ -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.
fn show_group_meta(&mut self, action: &nip29_ui::ShowGroupMeta, cx: &mut Context<Self>) {
let Ok(id) = GroupId::new(action.group.clone()) else {
@@ -453,6 +472,7 @@ impl Render for Workspace {
div()
.id("workspace")
.on_action(cx.listener(Self::on_command))
.on_action(cx.listener(Self::on_create_channel))
.relative()
.size_full()
.child(self.dock.clone())