feat: add support for NIP-29 #56
+14
-13
@@ -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<Group>, 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<Group>, 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<Group>, 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<Group>, 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<Group>,
|
||||
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<Result<()>>) {
|
||||
window
|
||||
|
||||
@@ -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<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 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<Group>,
|
||||
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<Vec<String>>, 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()
|
||||
}
|
||||
@@ -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<T: Selectable + IntoElement + 'static> {
|
||||
id: ElementId,
|
||||
|
||||
+26
-5
@@ -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<AnyElement>,
|
||||
label: SharedString,
|
||||
suffix: Option<AnyElement>,
|
||||
selected: bool,
|
||||
clickable: bool,
|
||||
#[allow(clippy::type_complexity)]
|
||||
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||
}
|
||||
|
||||
impl Nav {
|
||||
pub fn new(id: impl Into<ElementId>) -> 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()
|
||||
|
||||
@@ -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<Group>, cx: &mut Context<Self>) -> 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<usize>,
|
||||
rows: &[GroupRow],
|
||||
group: &WeakEntity<Group>,
|
||||
admin: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Vec<AnyElement> {
|
||||
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<Group>,
|
||||
admin: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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()
|
||||
}
|
||||
|
||||
+7
-5
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user