move group actions to sidebar and add invite link copying

This commit is contained in:
2026-10-01 09:52:41 +07:00
parent 857795a92e
commit 75b0f49d9e
12 changed files with 310 additions and 353 deletions
+4
View File
@@ -145,6 +145,10 @@ impl Group {
.map(SharedString::from)
}
pub fn total_members(&self) -> usize {
self.members.len()
}
pub fn members(&self) -> &GroupMembers {
&self.members
}
+9
View File
@@ -544,6 +544,15 @@ impl GroupMembers {
pub fn iter(&self) -> impl Iterator<Item = &PublicKey> {
self.0.iter()
}
pub fn len(&self) -> usize {
self.0.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
+62 -9
View File
@@ -1,15 +1,21 @@
use gpui::{App, AppContext, ParentElement, Styled, WeakEntity, Window, div, px};
use anyhow::Result;
use gpui::{
App, AppContext, ClipboardItem, ParentElement, SharedString, Styled, WeakEntity, Window, div,
px,
};
use nip29::Group;
use nostr_sdk::prelude::SecretKey;
use theme::ActiveTheme;
use ui::dialog::DialogButtonProps;
use ui::input::InputState;
use ui::notification::Notification;
use ui::{WindowExtension, v_flex};
use crate::{GroupPanel, field};
use crate::field;
const CODE_LEN: usize = 12;
pub(crate) fn open(panel: WeakEntity<GroupPanel>, window: &mut Window, cx: &mut App) {
pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
let code = SecretKey::generate().to_secret_hex();
let code: String = code.chars().take(CODE_LEN).collect();
@@ -20,7 +26,7 @@ pub(crate) fn open(panel: WeakEntity<GroupPanel>, window: &mut Window, cx: &mut
});
window.open_dialog(cx, move |this, _window, cx| {
let panel = panel.clone();
let group = group.clone();
let input = input.clone();
this.width(px(420.))
@@ -40,17 +46,64 @@ pub(crate) fn open(panel: WeakEntity<GroupPanel>, window: &mut Window, cx: &mut
)
.child(field("Code", &input, cx)),
)
.on_ok(move |_event, _window, cx| {
.on_ok(move |_event, window, cx| {
let code = input.read(cx).value().trim().to_owned();
if code.is_empty() {
return false;
}
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
panel.create_invite(code, window, cx);
}) {
log::warn!("nip29: creating an invite failed: {error}");
let link = group
.read_with(cx, |group, _cx| {
group.metadata().and_then(|metadata| {
metadata
.naddr(group.key().relay(), Some(&code))
.ok()
.map(SharedString::from)
})
})
.ok()
.flatten();
let Some(link) = link else {
window.push_notification(
Notification::error(
"The relay doesn't publish group details, so no invite link can be made",
),
cx,
);
return false;
};
match group.update(cx, |group, cx| group.create_invite(&code, cx)) {
Ok(task) => {
window
.spawn(cx, async move |cx| -> Result<()> {
if let Err(error) = task.await {
cx.update(|window, cx| {
window.push_notification(
Notification::error(error.to_string()),
cx,
);
})?;
return Ok(());
}
cx.update(|window, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(
link.to_string(),
));
window.push_notification(
Notification::success("Invite link copied"),
cx,
);
})?;
Ok(())
})
.detach();
}
Err(error) => log::warn!("nip29: creating an invite failed: {error}"),
}
true
+154 -285
View File
@@ -4,12 +4,12 @@ use anyhow::Result;
use chat_ui::text::rendered_text;
use gpui::prelude::FluentBuilder;
use gpui::{
Action, AnyElement, App, AppContext, ClipboardItem, Context, Div, Entity, EventEmitter,
FocusHandle, Focusable, FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState,
ParentElement, PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity,
Window, div, list, px,
Action, AnyElement, App, AppContext, Context, Div, Entity, EventEmitter, FocusHandle,
Focusable, FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement,
PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div,
list, px,
};
use nip29::{Group, GroupEvent, GroupMetadata, GroupsRegistry, Membership, Pin, Row};
use nip29::{Group, GroupEvent, GroupsRegistry, Membership, Pin, Row};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use serde::Deserialize;
@@ -23,7 +23,6 @@ use ui::dialog::DialogButtonProps;
use ui::dock::{Panel, PanelEvent};
use ui::input::{Input, InputEvent, InputState, Textarea, TextareaState};
use ui::markdown::RenderedText;
use ui::menu::{DropdownMenu, PopupMenuItem};
use ui::message::WelcomeMessage;
use ui::notification::Notification;
use ui::scroll::Scrollbar;
@@ -159,6 +158,130 @@ pub(crate) fn pin_label(group: &Group, pin: &Pin) -> SharedString {
}
}
/// Build the group's action buttons for the sidebar: info, admin actions and leave.
pub fn actions(group: WeakEntity<Group>, cx: &App) -> AnyElement {
let nostr = NostrRegistry::global(cx);
let me = nostr.read(cx).current_user();
let admin = group
.read_with(cx, |group, _cx| {
me.is_some_and(|me| group.admins().contains(&me))
})
.unwrap_or(false);
let info = group.clone();
let edit = group.clone();
let invite = group.clone();
let members = group.clone();
let leave = group;
h_flex()
.gap_1()
.child(
Button::new("group-info")
.icon(IconName::Info)
.tooltip("Group info")
.ghost()
.small()
.on_click(move |_event, window, cx| {
details::open(info.clone(), window, cx);
}),
)
.when(admin, |this| {
this.child(
Button::new("edit")
.icon(IconName::Edit)
.tooltip("Edit group")
.ghost()
.small()
.on_click(move |_event, window, cx| {
metadata::open(edit.clone(), window, cx);
}),
)
.child(
Button::new("invite")
.icon(IconName::Invite)
.tooltip("Invite people")
.ghost()
.small()
.on_click(move |_event, window, cx| {
invite::open(invite.clone(), window, cx);
}),
)
.child(
Button::new("members")
.icon(IconName::Group)
.tooltip("Members")
.ghost()
.small()
.on_click(move |_event, window, cx| {
users::open(members.clone(), window, cx);
}),
)
})
.child(
Button::new("leave")
.icon(IconName::Logout)
.tooltip("Leave group")
.ghost()
.small()
.on_click(move |_event, window, cx| {
confirm_leave(leave.clone(), window, cx);
}),
)
.into_any_element()
}
pub fn confirm_leave(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
let Some(name) = group.read_with(cx, |group, _cx| group.display_name()).ok() else {
return;
};
let Some(key) = group.read_with(cx, |group, _cx| group.key().clone()).ok() else {
return;
};
window.open_dialog(cx, move |this, _window, _cx| {
let key = key.clone();
this.confirm()
.width(px(360.))
.title(format!("Leave {name}?"))
.button_props(
DialogButtonProps::default()
.cancel_text("Cancel")
.ok_text("Leave")
.ok_variant(ButtonVariant::Danger),
)
.child(
div()
.text_sm()
.child("You will stop receiving messages from this group."),
)
.on_ok(move |_event, _window, cx| {
let registry = GroupsRegistry::global(cx);
registry.update(cx, |registry, cx| {
registry.leave(&key, cx);
});
true
})
});
}
/// Await a group write and surface any relay error as a notification.
pub(crate) fn report(window: &mut Window, cx: &mut App, task: Task<Result<()>>) {
window
.spawn(cx, async move |cx| -> Result<()> {
if let Err(error) = task.await {
cx.update(|window, cx| {
window.push_notification(Notification::error(error.to_string()), cx);
})?;
}
Ok(())
})
.detach();
}
pub struct GroupPanel {
id: SharedString,
focus_handle: FocusHandle,
@@ -409,121 +532,6 @@ impl GroupPanel {
.and_then(|group| Notice::of(group.read(cx)))
}
fn edit_metadata(
&mut self,
metadata: GroupMetadata,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Ok(edit) = self
.group
.update(cx, |group, cx| group.edit_metadata(&metadata, cx))
else {
return;
};
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
if let Err(error) = edit.await {
this.update_in(cx, |_this, window, cx| {
window.push_notification(Notification::error(error.to_string()), cx);
})?;
}
Ok(())
}));
}
fn create_invite(&mut self, code: String, window: &mut Window, cx: &mut Context<Self>) {
let Some(group) = self.group.upgrade() else {
return;
};
let link = {
let group = group.read(cx);
group.metadata().and_then(|metadata| {
metadata
.naddr(group.key().relay(), Some(&code))
.ok()
.map(SharedString::from)
})
};
let Some(link) = link else {
window.push_notification(
Notification::error(
"The relay doesn't publish group details, so no invite link can be made",
),
cx,
);
return;
};
let Ok(invite) = self
.group
.update(cx, |group, cx| group.create_invite(&code, cx))
else {
return;
};
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
if let Err(error) = invite.await {
this.update_in(cx, |_this, window, cx| {
window.push_notification(Notification::error(error.to_string()), cx);
})?;
return Ok(());
}
this.update_in(cx, |_this, window, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(link.to_string()));
window.push_notification(Notification::success("Invite link copied"), cx);
})?;
Ok(())
}));
}
fn put_user(
&mut self,
public_key: PublicKey,
roles: Vec<String>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Ok(put) = self
.group
.update(cx, |group, cx| group.put_user(public_key, roles, cx))
else {
return;
};
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
if let Err(error) = put.await {
this.update_in(cx, |_this, window, cx| {
window.push_notification(Notification::error(error.to_string()), cx);
})?;
}
Ok(())
}));
}
fn remove_user(&mut self, public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) {
let Ok(remove) = self
.group
.update(cx, |group, cx| group.remove_user(public_key, cx))
else {
return;
};
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
if let Err(error) = remove.await {
this.update_in(cx, |_this, window, cx| {
window.push_notification(Notification::error(error.to_string()), cx);
})?;
}
Ok(())
}));
}
fn react(
&mut self,
group: WeakEntity<Group>,
@@ -599,169 +607,6 @@ impl GroupPanel {
}));
}
fn confirm_leave(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
let Some(name) = group.read_with(cx, |group, _cx| group.display_name()).ok() else {
return;
};
let Some(key) = group.read_with(cx, |group, _cx| group.key().clone()).ok() else {
return;
};
window.open_dialog(cx, move |this, _window, _cx| {
let key = key.clone();
this.confirm()
.width(px(360.))
.title(format!("Leave {name}?"))
.button_props(
DialogButtonProps::default()
.cancel_text("Cancel")
.ok_text("Leave")
.ok_variant(ButtonVariant::Danger),
)
.child(
div()
.text_sm()
.child("You will stop receiving messages from this group."),
)
.on_ok(move |_event, _window, cx| {
let registry = GroupsRegistry::global(cx);
registry.update(cx, |registry, cx| {
registry.leave(&key, cx);
});
true
})
});
}
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let Some(group) = self.group.upgrade() else {
return div().into_any_element();
};
let (about, relay, group_id, admin) = {
let group = group.read(cx);
let about = group
.metadata()
.and_then(|metadata| metadata.about.clone())
.filter(|about| !about.trim().is_empty())
.map(SharedString::from);
let admin = NostrRegistry::global(cx)
.read(cx)
.current_user()
.is_some_and(|me| group.admins().contains(&me));
(
about,
group.key().relay().clone(),
group.key().id().as_str().to_owned(),
admin,
)
};
let panel = cx.weak_entity();
let details_group = self.group.clone();
let leave_group = self.group.clone();
h_flex()
.flex_shrink_0()
.w_full()
.h_10()
.px_2()
.gap_2()
.border_b_1()
.border_color(cx.theme().border)
.child(
div()
.flex_1()
.min_w_0()
.truncate()
.text_sm()
.text_color(cx.theme().text_muted)
.when_some(about, |this, about| this.child(about)),
)
.child(
Button::new("group-info")
.icon(IconName::PanelLeftOpen)
.tooltip("Toggle group meta panel in the sidebar")
.ghost()
.small()
.on_click(move |_event, window, cx| {
window.dispatch_action(
Box::new(ToggleGroupMeta {
relay: relay.clone(),
group: group_id.clone(),
}),
cx,
);
}),
)
.child(
Button::new("group-details")
.icon(IconName::Info)
.tooltip("Details")
.ghost()
.small()
.on_click(move |_event, window, cx| {
details::open(details_group.clone(), window, cx);
}),
)
.when(admin, |this| {
this.child(
Button::new("group-manage")
.icon(IconName::Ellipsis)
.tooltip("Manage")
.ghost()
.small()
.dropdown_menu({
let group = self.group.clone();
move |menu, _window, _cx| {
menu.item(PopupMenuItem::new("Edit group").on_click({
let group = group.clone();
let panel = panel.clone();
move |_event, window, cx| {
metadata::open(group.clone(), panel.clone(), window, cx);
}
}))
.item(PopupMenuItem::new("Invite people").on_click({
let panel = panel.clone();
move |_event, window, cx| {
invite::open(panel.clone(), window, cx);
}
}))
.item(
PopupMenuItem::new("Members and roles").on_click({
let group = group.clone();
let panel = panel.clone();
move |_event, window, cx| {
users::open(group.clone(), panel.clone(), window, cx);
}
}),
)
}
}),
)
})
.child(
Button::new("group-leave")
.icon(IconName::Door)
.tooltip("Leave")
.ghost()
.small()
.on_click(move |_event, window, cx| {
Self::confirm_leave(leave_group.clone(), window, cx);
}),
)
.into_any_element()
}
fn render_banner(&self, cx: &mut Context<Self>) -> AnyElement {
let Some(group) = self.group.upgrade() else {
return div().into_any_element();
@@ -1024,6 +869,15 @@ impl GroupPanel {
supports_text && writable
});
let meta = self.group.upgrade().map(|group| {
let group = group.read(cx);
(
group.key().relay().clone(),
group.key().id().as_str().to_owned(),
)
});
v_flex()
.flex_shrink_0()
.w_full()
@@ -1034,6 +888,22 @@ impl GroupPanel {
h_flex()
.gap_1()
.items_end()
.child(
Button::new("info")
.icon(IconName::Group)
.tooltip("Toggle group information in the sidebar")
.ghost()
.large()
.on_click(move |_event, window, cx| {
let Some((relay, group)) = meta.clone() else {
return;
};
window.dispatch_action(
Box::new(ToggleGroupMeta { relay, group }),
cx,
);
}),
)
.child(
Button::new("upload")
.icon(IconName::Upload)
@@ -1147,7 +1017,6 @@ impl Render for GroupPanel {
v_flex()
.size_full()
.min_w_0()
.child(self.render_header(cx))
.child(self.render_banner(cx))
.child(self.render_pins(cx))
.child(
+6 -13
View File
@@ -5,7 +5,7 @@ use ui::input::InputState;
use ui::switch::Switch;
use ui::{WindowExtension, v_flex};
use crate::{GroupPanel, field};
use crate::field;
#[derive(Clone, Copy, Default)]
struct Flags {
@@ -28,12 +28,7 @@ impl Flags {
}
}
pub(crate) fn open(
group: WeakEntity<Group>,
panel: WeakEntity<GroupPanel>,
window: &mut Window,
cx: &mut App,
) {
pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
let base = group
.read_with(cx, |group, _cx| group.metadata().cloned())
.ok()
@@ -119,13 +114,12 @@ pub(crate) fn open(
)
.on_ok({
let group = group.clone();
let panel = panel.clone();
let name = name.clone();
let about = about.clone();
let picture = picture.clone();
let flags = flags.clone();
move |_event, _window, cx| {
move |_event, window, cx| {
let mut next = group
.read_with(cx, |group, _cx| group.metadata().cloned())
.ok()
@@ -143,10 +137,9 @@ pub(crate) fn open(
next.closed = current.closed;
next.livekit = current.livekit;
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
panel.edit_metadata(next, window, cx);
}) {
log::warn!("nip29: editing the group failed: {error}");
match group.update(cx, |group, cx| group.edit_metadata(&next, cx)) {
Ok(task) => crate::report(window, cx, task),
Err(error) => log::warn!("nip29: editing the group failed: {error}"),
}
true
+18 -20
View File
@@ -14,20 +14,14 @@ use ui::notification::Notification;
use ui::switch::Switch;
use ui::{Sizable, StyledExt, WindowExtension, divider, h_flex, v_flex};
use crate::{GroupPanel, field};
use crate::{field, report};
pub(crate) fn open(
group: WeakEntity<Group>,
panel: WeakEntity<GroupPanel>,
window: &mut Window,
cx: &mut App,
) {
pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("npub1…"));
let selected = cx.new(|_cx| Vec::<String>::new());
window.open_dialog(cx, move |this, _window, cx| {
let group = group.clone();
let panel = panel.clone();
let input = input.clone();
let selected = selected.clone();
@@ -61,7 +55,7 @@ pub(crate) fn open(
.children(
admins
.iter()
.map(|(key, roles)| admin(key, roles, &panel, cx)),
.map(|(key, roles)| admin(key, roles, &group, cx)),
),
)
.child(divider(cx))
@@ -80,7 +74,7 @@ pub(crate) fn open(
)
})
.child(Button::new("add-user").label("Add").small().on_click({
let panel = panel.clone();
let group = group.clone();
let input = input.clone();
let selected = selected.clone();
@@ -100,10 +94,13 @@ pub(crate) fn open(
let roles = selected.read(cx).clone();
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
panel.put_user(public_key, roles, window, cx);
match group.update(cx, |group, cx| {
group.put_user(public_key, roles, cx)
}) {
log::warn!("nip29: adding a group member failed: {error}");
Ok(task) => report(window, cx, task),
Err(error) => log::warn!(
"nip29: adding a group member failed: {error}"
),
}
input.update(cx, |input, cx| {
@@ -123,7 +120,7 @@ pub(crate) fn open(
fn admin(
public_key: &PublicKey,
roles: &[String],
panel: &WeakEntity<GroupPanel>,
group: &WeakEntity<Group>,
cx: &App,
) -> AnyElement {
let persons = PersonRegistry::global(cx);
@@ -163,13 +160,14 @@ fn admin(
.ghost()
.small()
.on_click({
let panel = panel.clone();
let group = group.clone();
move |_event, _window, cx| {
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
panel.remove_user(public_key, window, cx);
}) {
log::warn!("nip29: removing a group member failed: {error}");
move |_event, window, cx| match group
.update(cx, |group, cx| group.remove_user(public_key, cx))
{
Ok(task) => report(window, cx, task),
Err(error) => {
log::warn!("nip29: removing a group member failed: {error}")
}
}
}),
+4
View File
@@ -50,6 +50,7 @@ pub enum IconName {
Link,
Loader,
Lock,
Logout,
Message,
Moon,
Plus,
@@ -72,6 +73,7 @@ pub enum IconName {
UserKey,
Upload,
Usb,
Edit,
PanelLeft,
PanelLeftOpen,
PanelRight,
@@ -129,6 +131,7 @@ impl IconNamed for IconName {
Self::Link => "icons/link.svg",
Self::Loader => "icons/loader.svg",
Self::Lock => "icons/lock.svg",
Self::Logout => "icons/logout.svg",
Self::Message => "icons/message.svg",
Self::Moon => "icons/moon.svg",
Self::Plus => "icons/plus.svg",
@@ -158,6 +161,7 @@ impl IconNamed for IconName {
Self::PanelBottom => "icons/panel-bottom.svg",
Self::PanelBottomOpen => "icons/panel-bottom-open.svg",
Self::PaperPlaneFill => "icons/paper-plane-fill.svg",
Self::Edit => "icons/edit.svg",
Self::Warning => "icons/warning.svg",
Self::WindowClose => "icons/window-close.svg",
Self::WindowMaximize => "icons/window-maximize.svg",
+41 -14
View File
@@ -11,7 +11,7 @@ use gpui::{
AnyElement, App, AppContext, Context, Div, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString, Stateful,
Styled, StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, div, img,
px, retain_all, uniform_list,
px, relative, retain_all, uniform_list,
};
use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry, Membership};
use nostr_sdk::prelude::*;
@@ -922,9 +922,11 @@ impl Sidebar {
}
fn render_group(&mut self, group: Entity<Group>, cx: &mut Context<Self>) -> AnyElement {
let (banner, name, seed, picture, rows) = {
let (banner, name, seed, picture, total_members, rows) = {
let group = group.read(cx);
let admins = group.admins();
let metadata = group.metadata();
let mut admin_rows = Vec::new();
let mut member_rows = Vec::new();
@@ -962,8 +964,6 @@ impl Sidebar {
}
}
let metadata = group.metadata();
(
metadata.and_then(|metadata| {
metadata.banner.clone().or_else(|| metadata.picture.clone())
@@ -971,6 +971,7 @@ impl Sidebar {
group.display_name(),
group.key().cache_tag(),
group.display_image(),
SharedString::from(format!("{} members", group.total_members())),
rows,
)
};
@@ -988,26 +989,52 @@ impl Sidebar {
div()
.h_40()
.w_full()
.relative()
.overflow_hidden()
.rounded(cx.theme().radius_lg)
.child(img(banner).size_full().object_fit(ObjectFit::Cover)),
.child(
img(banner)
.size_full()
.rounded(cx.theme().radius_lg)
.object_fit(ObjectFit::Fill),
),
),
)
})
.child(
h_flex()
.px_2()
.gap_2()
.flex_shrink_0()
.child(Avatar::from_source(picture).seed(seed).small())
.justify_between()
.child(
div()
.flex_1()
.min_w_0()
.text_sm()
.font_medium()
.truncate()
.child(name),
h_flex()
.gap_2()
.flex_shrink_1()
.child(Avatar::from_source(picture).seed(seed).small())
.child(
v_flex()
.flex_1()
.min_w_0()
.child(
div()
.text_xs()
.truncate()
.line_height(relative(1.2))
.font_semibold()
.child(name),
)
.child(
div()
.text_size(px(10.))
.text_color(cx.theme().text_muted)
.child(total_members),
),
),
)
.child(
h_flex()
.gap_1()
.child(nip29_ui::actions(group.downgrade(), cx)),
),
)
.child(