diff --git a/assets/icons/edit.svg b/assets/icons/edit.svg
new file mode 100644
index 00000000..9387f08a
--- /dev/null
+++ b/assets/icons/edit.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/logout.svg b/assets/icons/logout.svg
new file mode 100644
index 00000000..05534526
--- /dev/null
+++ b/assets/icons/logout.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/upload.svg b/assets/icons/upload.svg
index cfb5e305..d92353e7 100644
--- a/assets/icons/upload.svg
+++ b/assets/icons/upload.svg
@@ -1,3 +1 @@
-
+
diff --git a/crates/nip29/src/group.rs b/crates/nip29/src/group.rs
index cca2489a..dace77c6 100644
--- a/crates/nip29/src/group.rs
+++ b/crates/nip29/src/group.rs
@@ -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
}
diff --git a/crates/nip29/src/protocol.rs b/crates/nip29/src/protocol.rs
index 8c38bb19..151db3fe 100644
--- a/crates/nip29/src/protocol.rs
+++ b/crates/nip29/src/protocol.rs
@@ -544,6 +544,15 @@ impl GroupMembers {
pub fn iter(&self) -> impl Iterator- {
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)]
diff --git a/crates/nip29_ui/src/invite.rs b/crates/nip29_ui/src/invite.rs
index 65314c8b..8f7d2688 100644
--- a/crates/nip29_ui/src/invite.rs
+++ b/crates/nip29_ui/src/invite.rs
@@ -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, window: &mut Window, cx: &mut App) {
+pub(crate) fn open(group: WeakEntity, 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, 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, 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
diff --git a/crates/nip29_ui/src/lib.rs b/crates/nip29_ui/src/lib.rs
index b3bbc182..10d9ded2 100644
--- a/crates/nip29_ui/src/lib.rs
+++ b/crates/nip29_ui/src/lib.rs
@@ -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, 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, 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>) {
+ 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,
- ) {
- 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) {
- 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,
- window: &mut Window,
- cx: &mut Context,
- ) {
- 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) {
- 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,
@@ -599,169 +607,6 @@ impl GroupPanel {
}));
}
- fn confirm_leave(group: WeakEntity, 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) -> 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) -> 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(
diff --git a/crates/nip29_ui/src/metadata.rs b/crates/nip29_ui/src/metadata.rs
index e5c8c58d..bc122846 100644
--- a/crates/nip29_ui/src/metadata.rs
+++ b/crates/nip29_ui/src/metadata.rs
@@ -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,
- panel: WeakEntity,
- window: &mut Window,
- cx: &mut App,
-) {
+pub(crate) fn open(group: WeakEntity, 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
diff --git a/crates/nip29_ui/src/users.rs b/crates/nip29_ui/src/users.rs
index 4abca9c1..ba990a95 100644
--- a/crates/nip29_ui/src/users.rs
+++ b/crates/nip29_ui/src/users.rs
@@ -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,
- panel: WeakEntity,
- window: &mut Window,
- cx: &mut App,
-) {
+pub(crate) fn open(group: WeakEntity, window: &mut Window, cx: &mut App) {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("npub1…"));
let selected = cx.new(|_cx| Vec::::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,
+ group: &WeakEntity,
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}")
}
}
}),
diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs
index e47f01df..1e708c06 100644
--- a/crates/ui/src/icon.rs
+++ b/crates/ui/src/icon.rs
@@ -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",
diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs
index 9bbdf144..b84ffd53 100644
--- a/crates/workspace/src/sidebar/mod.rs
+++ b/crates/workspace/src/sidebar/mod.rs
@@ -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, cx: &mut Context) -> 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(
diff --git a/docs/nip29-usage.md b/docs/nip29-usage.md
index 0955887c..01a686de 100644
--- a/docs/nip29-usage.md
+++ b/docs/nip29-usage.md
@@ -588,15 +588,15 @@ before they reach the consumer, so a replay cannot double-hydrate.
- **Administration is built on the client side.** The sidebar's `Groups` tab lists
`GroupsRegistry::groups()`, opens a `GroupPanel`, and offers the new-group and
join dialogs; `crates/workspace/src/panels/browse.rs` browses a relay and joins
- from a result. The panel has a read-only details dialog, a header `Manage` menu
- (edit metadata, invite link, members and roles) shown to admins, a message menu
- with reactions and admin pin/delete, a pins strip, a fork banner, and leave.
- Opening a group also fills the sidebar with the group itself — banner (the
- `39000` `banner` tag, falling back to its `picture`), avatar and name, and the
- collapsible `Admins`/`Members` lists. Members are clickable and open a profile
- dialog. The panel header shows the group's `about` instead of its name, plus a
- button that shows or hides the sidebar's group information. What a given account
- may actually do is the relay's call; the client shows the refusal verbatim.
+ from a result. The panel has a read-only details dialog, a message menu with
+ reactions and admin pin/delete, a pins strip, a fork banner, and leave. Opening a
+ group also fills the sidebar with the group itself — banner (the `39000` `banner`
+ tag, falling back to its `picture`), avatar and name, a row of info / edit / invite /
+ members / leave buttons, and the collapsible `Admins`/`Members` lists. Members are
+ clickable and open a profile dialog. The panel has no header: the group's `about` lives in the
+ details dialog, and the composer's leading button shows or hides the sidebar's
+ group information. What a given account may actually do is the relay's call; the
+ client shows the refusal verbatim.
- **The composer uploads to the configured blossom server.** `GroupPanel`'s
paperclip uploads one file at a time, unencrypted, and appends its URL to the
message on send — the same plain-attachment path the chat composer uses when a