diff --git a/crates/nip29_ui/src/lib.rs b/crates/nip29_ui/src/lib.rs index 10d9ded2..05733a4b 100644 --- a/crates/nip29_ui/src/lib.rs +++ b/crates/nip29_ui/src/lib.rs @@ -32,7 +32,6 @@ mod details; mod invite; mod message; mod metadata; -mod users; /// Ask the shell to show or hide the sidebar's information for a group. #[derive(Action, Clone, PartialEq, Eq, Deserialize)] @@ -172,7 +171,6 @@ pub fn actions(group: WeakEntity, cx: &App) -> AnyElement { let info = group.clone(); let edit = group.clone(); let invite = group.clone(); - let members = group.clone(); let leave = group; h_flex() @@ -199,7 +197,7 @@ pub fn actions(group: WeakEntity, cx: &App) -> AnyElement { }), ) .child( - Button::new("invite") + Button::new("group-invite") .icon(IconName::Invite) .tooltip("Invite people") .ghost() @@ -208,16 +206,6 @@ pub fn actions(group: WeakEntity, cx: &App) -> AnyElement { 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") @@ -268,6 +256,19 @@ pub fn confirm_leave(group: WeakEntity, window: &mut Window, cx: &mut App }); } +/// Remove a member from the group and surface any relay error as a notification. +pub fn remove_member( + group: WeakEntity, + public_key: PublicKey, + window: &mut Window, + cx: &mut App, +) { + 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}"), + } +} + /// 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 diff --git a/crates/nip29_ui/src/users.rs b/crates/nip29_ui/src/users.rs deleted file mode 100644 index ba990a95..00000000 --- a/crates/nip29_ui/src/users.rs +++ /dev/null @@ -1,207 +0,0 @@ -use gpui::prelude::FluentBuilder; -use gpui::{ - AnyElement, App, AppContext, Entity, IntoElement, ParentElement, SharedString, Styled, - WeakEntity, Window, div, px, -}; -use nip29::{Group, Role}; -use nostr_sdk::prelude::*; -use person::PersonRegistry; -use theme::ActiveTheme; -use ui::avatar::Avatar; -use ui::button::{Button, ButtonVariants}; -use ui::input::InputState; -use ui::notification::Notification; -use ui::switch::Switch; -use ui::{Sizable, StyledExt, WindowExtension, divider, h_flex, v_flex}; - -use crate::{field, report}; - -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 input = input.clone(); - let selected = selected.clone(); - - let admins = group - .read_with(cx, |group, _cx| group.admins().entries().to_vec()) - .unwrap_or_default(); - let roles = group - .read_with(cx, |group, _cx| group.roles().roles().to_vec()) - .unwrap_or_default(); - let chosen = selected.read(cx).clone(); - - this.width(px(460.)) - .max_h(px(620.)) - .show_close(true) - .title("Members and roles") - .child( - v_flex() - .gap_4() - .child( - v_flex() - .gap_2() - .child(div().text_sm().font_medium().child("Admins")) - .when(admins.is_empty(), |this| { - this.child( - div() - .text_sm() - .text_color(cx.theme().text_placeholder) - .child("The relay lists no admins."), - ) - }) - .children( - admins - .iter() - .map(|(key, roles)| admin(key, roles, &group, cx)), - ), - ) - .child(divider(cx)) - .child( - v_flex() - .gap_3() - .child(div().text_sm().font_medium().child("Add a member")) - .child(field("Public key", &input, cx)) - .when(!roles.is_empty(), |this| { - this.child( - v_flex().gap_2().children( - roles - .iter() - .map(|role| role_switch(role, &selected, &chosen)), - ), - ) - }) - .child(Button::new("add-user").label("Add").small().on_click({ - let group = group.clone(); - let input = input.clone(); - let selected = selected.clone(); - - move |_event, window, cx| { - let value = input.read(cx).value().trim().to_owned(); - - let public_key = match PublicKey::parse(&value) { - Ok(public_key) => public_key, - Err(error) => { - window.push_notification( - Notification::error(error.to_string()), - cx, - ); - return; - } - }; - - let roles = selected.read(cx).clone(); - - match group.update(cx, |group, cx| { - group.put_user(public_key, roles, cx) - }) { - Ok(task) => report(window, cx, task), - Err(error) => log::warn!( - "nip29: adding a group member failed: {error}" - ), - } - - input.update(cx, |input, cx| { - input.set_value("", window, cx); - }); - selected.update(cx, |selected, cx| { - selected.clear(); - cx.notify(); - }); - } - })), - ), - ) - }); -} - -fn admin( - public_key: &PublicKey, - roles: &[String], - group: &WeakEntity, - cx: &App, -) -> AnyElement { - let persons = PersonRegistry::global(cx); - let person = persons.read(cx).get(public_key, cx); - let public_key = *public_key; - - h_flex() - .justify_between() - .gap_2() - .child( - h_flex() - .gap_2() - .min_w_0() - .child( - Avatar::new(person.avatar()) - .seed(person.avatar_seed()) - .xsmall(), - ) - .child( - v_flex() - .min_w_0() - .child(div().truncate().child(person.name())) - .when(!roles.is_empty(), |this| { - this.child( - div() - .truncate() - .text_xs() - .text_color(cx.theme().text_placeholder) - .child(SharedString::from(roles.join(", "))), - ) - }), - ), - ) - .child( - Button::new(SharedString::from(format!("del-{}", public_key.to_hex()))) - .label("Remove") - .ghost() - .small() - .on_click({ - let group = group.clone(); - - 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}") - } - } - }), - ) - .into_any_element() -} - -fn role_switch(role: &Role, selected: &Entity>, chosen: &[String]) -> AnyElement { - let name = role.name.clone(); - let description = role.description.clone(); - - Switch::new(SharedString::from(format!("role-{}", role.name))) - .label(role.name.clone()) - .when_some(description, |this, description| { - this.description(description) - }) - .small() - .checked(chosen.contains(&role.name)) - .on_click({ - let selected = selected.clone(); - - move |next, _window, cx| { - selected.update(cx, |selected, cx| { - if *next { - if !selected.contains(&name) { - selected.push(name.clone()); - } - } else { - selected.retain(|role| role != &name); - } - - cx.notify(); - }); - } - }) - .into_any_element() -} diff --git a/crates/ui/src/menu/dropdown_menu.rs b/crates/ui/src/menu/dropdown_menu.rs index f1a25534..64e6025a 100644 --- a/crates/ui/src/menu/dropdown_menu.rs +++ b/crates/ui/src/menu/dropdown_menu.rs @@ -9,6 +9,7 @@ use crate::Selectable; use crate::avatar::Avatar; use crate::button::Button; use crate::menu::PopupMenu; +use crate::nav::Nav; use crate::popover::{Popover, PopoverState}; /// Builds the items of a popup menu on each render. @@ -41,6 +42,8 @@ impl DropdownMenu for Button {} impl DropdownMenu for Avatar {} +impl DropdownMenu for Nav {} + #[derive(IntoElement)] pub struct DropdownMenuPopover { id: ElementId, diff --git a/crates/ui/src/nav.rs b/crates/ui/src/nav.rs index efa770f7..2193eb5d 100644 --- a/crates/ui/src/nav.rs +++ b/crates/ui/src/nav.rs @@ -2,9 +2,9 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder as _; use gpui::{ - AnyElement, App, ClickEvent, ElementId, InteractiveElement, IntoElement, ParentElement, - RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, - div, + AnyElement, App, ClickEvent, ElementId, InteractiveElement, Interactivity, IntoElement, + ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement, + Styled, Window, div, }; use theme::ActiveTheme; @@ -14,28 +14,43 @@ use crate::{Selectable, StyledExt, h_flex, v_flex}; #[derive(IntoElement)] pub struct Nav { id: ElementId, + interactivity: Interactivity, style: StyleRefinement, prefix: Option, label: SharedString, suffix: Option, selected: bool, + clickable: bool, #[allow(clippy::type_complexity)] on_click: Option>, } impl Nav { pub fn new(id: impl Into) -> Self { + let id = id.into(); + let mut interactivity = Interactivity::default(); + interactivity.element_id = Some(id.clone()); + Self { - id: id.into(), + id, + interactivity, style: StyleRefinement::default(), prefix: None, label: SharedString::default(), suffix: None, selected: false, + clickable: false, on_click: None, } } + /// Marks the row as clickable even when it has no `on_click` handler, so it + /// still shows the hover affordance. Used by rows whose click opens a menu. + pub fn clickable(mut self, clickable: bool) -> Self { + self.clickable = clickable; + self + } + /// Sets the element shown before the label, such as an avatar or icon. pub fn prefix(mut self, prefix: impl IntoElement) -> Self { self.prefix = Some(prefix.into_any_element()); @@ -74,6 +89,12 @@ impl Selectable for Nav { } } +impl InteractiveElement for Nav { + fn interactivity(&mut self) -> &mut Interactivity { + &mut self.interactivity + } +} + impl Styled for Nav { fn style(&mut self) -> &mut StyleRefinement { &mut self.style @@ -82,7 +103,7 @@ impl Styled for Nav { impl RenderOnce for Nav { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let clickable = self.on_click.is_some(); + let clickable = self.on_click.is_some() || self.clickable; v_flex().w_full().h_10().child( h_flex() diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index b84ffd53..5ff24cc3 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -8,10 +8,10 @@ use chat::{ChatEvent, ChatRegistry, Room, RoomKind}; use common::TimestampExt; use gpui::prelude::FluentBuilder; 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, relative, retain_all, uniform_list, + AnyElement, App, AppContext, ClipboardItem, Context, Div, Entity, EventEmitter, FocusHandle, + Focusable, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString, + Stateful, Styled, StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, + div, img, px, relative, retain_all, uniform_list, }; use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry, Membership}; use nostr_sdk::prelude::*; @@ -922,10 +922,14 @@ impl Sidebar { } fn render_group(&mut self, group: Entity, cx: &mut Context) -> AnyElement { - let (banner, name, seed, picture, total_members, rows) = { + let admin = NostrRegistry::global(cx) + .read(cx) + .current_user() + .is_some_and(|me| group.read(cx).admins().contains(&me)); + + let (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(); @@ -965,9 +969,6 @@ impl Sidebar { } ( - metadata.and_then(|metadata| { - metadata.banner.clone().or_else(|| metadata.picture.clone()) - }), group.display_name(), group.key().cache_tag(), group.display_image(), @@ -977,30 +978,13 @@ impl Sidebar { }; let rows = Rc::new(rows); + let member_group = group.downgrade(); v_flex() .flex_1() .min_h_0() .w_full() .gap_2() - .when_some(banner, |this, banner| { - this.child( - div().px_2().flex_shrink_0().child( - div() - .h_40() - .w_full() - .relative() - .overflow_hidden() - .rounded(cx.theme().radius_lg) - .child( - img(banner) - .size_full() - .rounded(cx.theme().radius_lg) - .object_fit(ObjectFit::Fill), - ), - ), - ) - }) .child( h_flex() .px_2() @@ -1008,7 +992,8 @@ impl Sidebar { .justify_between() .child( h_flex() - .gap_2() + .pl_0p5() + .gap_1() .flex_shrink_1() .child(Avatar::from_source(picture).seed(seed).small()) .child( @@ -1046,7 +1031,13 @@ impl Sidebar { "group-rows", rows.len(), cx.processor(move |this, range, _window, cx| { - this.render_group_rows(range, rows.as_slice(), cx) + this.render_group_rows( + range, + rows.as_slice(), + &member_group, + admin, + cx, + ) }), ) .track_scroll(&self.group_scroll) @@ -1062,6 +1053,8 @@ impl Sidebar { &self, range: Range, rows: &[GroupRow], + group: &WeakEntity, + admin: bool, cx: &mut Context, ) -> Vec { rows.get(range) @@ -1069,7 +1062,9 @@ impl Sidebar { .flatten() .map(|row| match row { GroupRow::Section(section) => self.section_row(*section, cx), - GroupRow::Member { public_key, roles } => self.member_row(public_key, roles, cx), + GroupRow::Member { public_key, roles } => { + self.member_row(public_key, roles, group, admin, cx) + } }) .collect() } @@ -1106,16 +1101,22 @@ impl Sidebar { &self, public_key: &PublicKey, roles: &[String], + group: &WeakEntity, + admin: bool, cx: &mut Context, ) -> AnyElement { let hide_avatar = AppSettings::get_hide_avatar(cx); let persons = PersonRegistry::global(cx); let person = persons.read(cx).get(public_key, cx); + let public_key = *public_key; + + let group = group.clone(); Nav::new(public_key.to_hex()) .label(person.name()) .text_sm() .font_medium() + .clickable(true) .when(!hide_avatar, |this| { this.prefix( Avatar::new(person.avatar()) @@ -1133,10 +1134,29 @@ impl Sidebar { .child(SharedString::from(roles.join(", "))), ) }) - .on_click({ - let public_key = *public_key; + .dropdown_menu(move |menu, _window, _cx| { + let menu = menu + .item( + PopupMenuItem::new("View profile").on_click(move |_, window, cx| { + profile::open(public_key, window, cx); + }), + ) + .item(PopupMenuItem::new("Copy").on_click(move |_, _, cx| { + let npub = public_key.to_bech32().unwrap_or_default(); + cx.write_to_clipboard(ClipboardItem::new_string(npub)); + })); - move |_event, window, cx| profile::open(public_key, window, cx) + if !admin { + return menu; + } + + menu.separator() + .item(PopupMenuItem::new("Remove").on_click({ + let group = group.clone(); + move |_event, window, cx| { + nip29_ui::remove_member(group.clone(), public_key, window, cx); + } + })) }) .into_any_element() } diff --git a/docs/nip29-usage.md b/docs/nip29-usage.md index 01a686de..897df22f 100644 --- a/docs/nip29-usage.md +++ b/docs/nip29-usage.md @@ -592,11 +592,13 @@ before they reach the consumer, so a replay cannot double-hydrate. 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. + leave buttons, and the collapsible `Admins`/`Members` lists. Clicking a member opens + a dropdown menu (view profile, copy npub, and — for admins — remove from group). + There is no members-and-roles dialog: adding a member is done through the invite + flow, and roles are read-only. 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