feat: add support for NIP-29 #56

Merged
reya merged 27 commits from feat/nip29 into master 2026-10-01 04:23:21 +00:00
6 changed files with 103 additions and 263 deletions
Showing only changes of commit a93be19e81 - Show all commits
+14 -13
View File
@@ -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
-207
View File
@@ -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()
}
+3
View File
@@ -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
View File
@@ -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()
+53 -33
View File
@@ -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
View File
@@ -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