From 75b0f49d9e2f7af454b382b22072ad07ef45bef6 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 1 Oct 2026 09:52:41 +0700 Subject: [PATCH] move group actions to sidebar and add invite link copying --- assets/icons/edit.svg | 1 + assets/icons/logout.svg | 1 + assets/icons/upload.svg | 4 +- crates/nip29/src/group.rs | 4 + crates/nip29/src/protocol.rs | 9 + crates/nip29_ui/src/invite.rs | 71 ++++- crates/nip29_ui/src/lib.rs | 439 ++++++++++------------------ crates/nip29_ui/src/metadata.rs | 19 +- crates/nip29_ui/src/users.rs | 38 ++- crates/ui/src/icon.rs | 4 + crates/workspace/src/sidebar/mod.rs | 55 +++- docs/nip29-usage.md | 18 +- 12 files changed, 310 insertions(+), 353 deletions(-) create mode 100644 assets/icons/edit.svg create mode 100644 assets/icons/logout.svg 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