feat: add support for NIP-29 #56
@@ -0,0 +1,59 @@
|
|||||||
|
use gpui::{App, AppContext, ParentElement, Styled, WeakEntity, Window, div, px};
|
||||||
|
use nostr_sdk::prelude::SecretKey;
|
||||||
|
use theme::ActiveTheme;
|
||||||
|
use ui::dialog::DialogButtonProps;
|
||||||
|
use ui::input::InputState;
|
||||||
|
use ui::{WindowExtension, v_flex};
|
||||||
|
|
||||||
|
use crate::{GroupPanel, field};
|
||||||
|
|
||||||
|
const CODE_LEN: usize = 12;
|
||||||
|
|
||||||
|
pub(crate) fn open(panel: WeakEntity<GroupPanel>, window: &mut Window, cx: &mut App) {
|
||||||
|
let code = SecretKey::generate().to_secret_hex();
|
||||||
|
let code: String = code.chars().take(CODE_LEN).collect();
|
||||||
|
|
||||||
|
let input = cx.new(|cx| {
|
||||||
|
InputState::new(window, cx)
|
||||||
|
.placeholder("Invite code")
|
||||||
|
.default_value(code)
|
||||||
|
});
|
||||||
|
|
||||||
|
window.open_dialog(cx, move |this, _window, cx| {
|
||||||
|
let panel = panel.clone();
|
||||||
|
let input = input.clone();
|
||||||
|
|
||||||
|
this.width(px(420.))
|
||||||
|
.confirm()
|
||||||
|
.title("Invite link")
|
||||||
|
.button_props(
|
||||||
|
DialogButtonProps::default()
|
||||||
|
.cancel_text("Cancel")
|
||||||
|
.ok_text("Copy link"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex().gap_3().child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child("Share the code with whoever should be able to join. The relay must accept it."),
|
||||||
|
)
|
||||||
|
.child(field("Code", &input, 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}");
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
+203
-6
@@ -5,11 +5,11 @@ use chat::Message;
|
|||||||
use chat_ui::text::rendered_text;
|
use chat_ui::text::rendered_text;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, FollowMode,
|
AnyElement, App, AppContext, ClipboardItem, Context, Div, Entity, EventEmitter, FocusHandle,
|
||||||
IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, Render, SharedString,
|
Focusable, FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement,
|
||||||
Styled, Subscription, Task, WeakEntity, Window, div, list, px,
|
Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list, px,
|
||||||
};
|
};
|
||||||
use nip29::{Group, GroupEvent, GroupsRegistry, Membership, Pin};
|
use nip29::{Group, GroupEvent, GroupMetadata, GroupsRegistry, Membership, Pin};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use smallvec::{SmallVec, smallvec};
|
use smallvec::{SmallVec, smallvec};
|
||||||
@@ -19,15 +19,19 @@ use ui::avatar::Avatar;
|
|||||||
use ui::button::{Button, ButtonVariant, ButtonVariants};
|
use ui::button::{Button, ButtonVariant, ButtonVariants};
|
||||||
use ui::dialog::DialogButtonProps;
|
use ui::dialog::DialogButtonProps;
|
||||||
use ui::dock::{Panel, PanelEvent};
|
use ui::dock::{Panel, PanelEvent};
|
||||||
use ui::input::{InputEvent, Textarea, TextareaState};
|
use ui::input::{Input, InputEvent, InputState, Textarea, TextareaState};
|
||||||
use ui::markdown::RenderedText;
|
use ui::markdown::RenderedText;
|
||||||
|
use ui::menu::{DropdownMenu, PopupMenuItem};
|
||||||
use ui::message::WelcomeMessage;
|
use ui::message::WelcomeMessage;
|
||||||
use ui::notification::Notification;
|
use ui::notification::Notification;
|
||||||
use ui::scroll::Scrollbar;
|
use ui::scroll::Scrollbar;
|
||||||
use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
|
||||||
|
|
||||||
mod details;
|
mod details;
|
||||||
|
mod invite;
|
||||||
mod message;
|
mod message;
|
||||||
|
mod metadata;
|
||||||
|
mod users;
|
||||||
|
|
||||||
const LOAD_OLDER_THRESHOLD: usize = 20;
|
const LOAD_OLDER_THRESHOLD: usize = 20;
|
||||||
const RUN_WINDOW_SECS: u64 = 300;
|
const RUN_WINDOW_SECS: u64 = 300;
|
||||||
@@ -105,6 +109,15 @@ pub fn init(group: Entity<Group>, window: &mut Window, cx: &mut App) -> Entity<G
|
|||||||
cx.new(|cx| GroupPanel::new(group, window, cx))
|
cx.new(|cx| GroupPanel::new(group, window, cx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn field(label: &'static str, input: &Entity<InputState>, cx: &App) -> Div {
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(label)
|
||||||
|
.child(Input::new(input))
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn pin_label(group: &Group, pin: &Pin) -> SharedString {
|
pub(crate) fn pin_label(group: &Group, pin: &Pin) -> SharedString {
|
||||||
match pin {
|
match pin {
|
||||||
Pin::Address(coordinate) => SharedString::from(format!("{coordinate}")),
|
Pin::Address(coordinate) => SharedString::from(format!("{coordinate}")),
|
||||||
@@ -302,6 +315,143 @@ impl GroupPanel {
|
|||||||
.and_then(|group| Notice::of(group.read(cx)))
|
.and_then(|group| Notice::of(group.read(cx)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn edit_metadata(
|
||||||
|
&mut self,
|
||||||
|
metadata: GroupMetadata,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
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<Self>) {
|
||||||
|
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<String>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
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<Self>) {
|
||||||
|
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<Group>,
|
||||||
|
id: EventId,
|
||||||
|
emoji: String,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let Ok(react) = group.update(cx, |group, cx| group.send_reaction(id, emoji, cx)) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||||
|
if let Err(error) = react.await {
|
||||||
|
this.update_in(cx, |_this, window, cx| {
|
||||||
|
window.push_notification(Notification::error(error.to_string()), cx);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
fn delete_message(
|
fn delete_message(
|
||||||
&mut self,
|
&mut self,
|
||||||
group: WeakEntity<Group>,
|
group: WeakEntity<Group>,
|
||||||
@@ -396,15 +546,22 @@ impl GroupPanel {
|
|||||||
return div().into_any_element();
|
return div().into_any_element();
|
||||||
};
|
};
|
||||||
|
|
||||||
let (name, picture, seed) = {
|
let (name, picture, seed, admin) = {
|
||||||
let group = group.read(cx);
|
let group = group.read(cx);
|
||||||
|
let admin = NostrRegistry::global(cx)
|
||||||
|
.read(cx)
|
||||||
|
.current_user()
|
||||||
|
.is_some_and(|me| group.admins().contains(&me));
|
||||||
|
|
||||||
(
|
(
|
||||||
group.display_name(),
|
group.display_name(),
|
||||||
group.display_image(),
|
group.display_image(),
|
||||||
group.key().cache_tag(),
|
group.key().cache_tag(),
|
||||||
|
admin,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let panel = cx.weak_entity();
|
||||||
let details_group = self.group.clone();
|
let details_group = self.group.clone();
|
||||||
let leave_group = self.group.clone();
|
let leave_group = self.group.clone();
|
||||||
|
|
||||||
@@ -436,6 +593,46 @@ impl GroupPanel {
|
|||||||
details::open(details_group.clone(), 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(
|
.child(
|
||||||
Button::new("group-leave")
|
Button::new("group-leave")
|
||||||
.icon(IconName::Door)
|
.icon(IconName::Door)
|
||||||
|
|||||||
@@ -6,15 +6,18 @@ use gpui::{
|
|||||||
StyledImage, WeakEntity, div, img, px,
|
StyledImage, WeakEntity, div, img, px,
|
||||||
};
|
};
|
||||||
use nip29::Group;
|
use nip29::Group;
|
||||||
|
use nostr_sdk::prelude::EventId;
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use settings::AppSettings;
|
use settings::AppSettings;
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::avatar::Avatar;
|
use ui::avatar::Avatar;
|
||||||
use ui::menu::{ContextMenu, PopupMenuItem};
|
use ui::menu::{ContextMenu, PopupMenu, PopupMenuItem};
|
||||||
use ui::message::MessageRow;
|
use ui::message::MessageRow;
|
||||||
|
|
||||||
use crate::GroupPanel;
|
use crate::GroupPanel;
|
||||||
|
|
||||||
|
const REACTIONS: [&str; 6] = ["👍", "❤️", "😂", "🎉", "😮", "😢"];
|
||||||
|
|
||||||
pub(crate) struct Actions {
|
pub(crate) struct Actions {
|
||||||
pub group: WeakEntity<Group>,
|
pub group: WeakEntity<Group>,
|
||||||
pub panel: WeakEntity<GroupPanel>,
|
pub panel: WeakEntity<GroupPanel>,
|
||||||
@@ -50,11 +53,8 @@ pub(crate) fn render(
|
|||||||
})
|
})
|
||||||
.into_any_element();
|
.into_any_element();
|
||||||
|
|
||||||
if !actions.admin {
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
let id = message.id;
|
let id = message.id;
|
||||||
|
let admin = actions.admin;
|
||||||
let pinned = actions.pinned;
|
let pinned = actions.pinned;
|
||||||
let group = actions.group;
|
let group = actions.group;
|
||||||
let panel = actions.panel;
|
let panel = actions.panel;
|
||||||
@@ -62,41 +62,73 @@ pub(crate) fn render(
|
|||||||
ContextMenu::new(
|
ContextMenu::new(
|
||||||
format!("message-menu-{}", id.to_hex()),
|
format!("message-menu-{}", id.to_hex()),
|
||||||
row,
|
row,
|
||||||
move |menu, _window, _cx| {
|
move |menu, window, cx| {
|
||||||
let pin = {
|
let menu = menu.submenu("React", window, cx, {
|
||||||
let group = group.clone();
|
let group = group.clone();
|
||||||
let panel = panel.clone();
|
let panel = panel.clone();
|
||||||
|
|
||||||
PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" }).on_click(
|
move |menu, _window, _cx| react_menu(menu, group.clone(), panel.clone(), id)
|
||||||
move |_event, _window, cx| {
|
});
|
||||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
|
||||||
panel.toggle_pin(group.clone(), id, window, cx);
|
|
||||||
}) {
|
|
||||||
log::warn!("nip29: pinning a message failed: {error}");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
let delete = {
|
if !admin {
|
||||||
let group = group.clone();
|
return menu;
|
||||||
let panel = panel.clone();
|
}
|
||||||
|
|
||||||
PopupMenuItem::new("Delete message").on_click(move |_event, _window, cx| {
|
menu.separator()
|
||||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
.item({
|
||||||
panel.delete_message(group.clone(), id, window, cx);
|
let group = group.clone();
|
||||||
}) {
|
let panel = panel.clone();
|
||||||
log::warn!("nip29: deleting a message failed: {error}");
|
|
||||||
}
|
PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" }).on_click(
|
||||||
|
move |_event, _window, cx| {
|
||||||
|
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
||||||
|
panel.toggle_pin(group.clone(), id, window, cx);
|
||||||
|
}) {
|
||||||
|
log::warn!("nip29: pinning a message failed: {error}");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
})
|
})
|
||||||
};
|
.item({
|
||||||
|
let group = group.clone();
|
||||||
|
let panel = panel.clone();
|
||||||
|
|
||||||
menu.item(pin).item(delete)
|
PopupMenuItem::new("Delete message").on_click(move |_event, _window, cx| {
|
||||||
|
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
||||||
|
panel.delete_message(group.clone(), id, window, cx);
|
||||||
|
}) {
|
||||||
|
log::warn!("nip29: deleting a message failed: {error}");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn react_menu(
|
||||||
|
menu: PopupMenu,
|
||||||
|
group: WeakEntity<Group>,
|
||||||
|
panel: WeakEntity<GroupPanel>,
|
||||||
|
id: EventId,
|
||||||
|
) -> PopupMenu {
|
||||||
|
REACTIONS.iter().fold(menu, |menu, emoji| {
|
||||||
|
let emoji = *emoji;
|
||||||
|
let group = group.clone();
|
||||||
|
let panel = panel.clone();
|
||||||
|
|
||||||
|
menu.item(
|
||||||
|
PopupMenuItem::new(emoji).on_click(move |_event, _window, cx| {
|
||||||
|
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
||||||
|
panel.react(group.clone(), id, emoji.to_owned(), window, cx);
|
||||||
|
}) {
|
||||||
|
log::warn!("nip29: sending a reaction failed: {error}");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn media(media: &[SharedUri], cx: &App) -> AnyElement {
|
fn media(media: &[SharedUri], cx: &App) -> AnyElement {
|
||||||
if let [only] = media {
|
if let [only] = media {
|
||||||
return div()
|
return div()
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
use gpui::{App, AppContext, Entity, ParentElement, Styled, WeakEntity, Window, px};
|
||||||
|
use nip29::{Group, GroupMetadata};
|
||||||
|
use ui::dialog::DialogButtonProps;
|
||||||
|
use ui::input::InputState;
|
||||||
|
use ui::switch::Switch;
|
||||||
|
use ui::{WindowExtension, v_flex};
|
||||||
|
|
||||||
|
use crate::{GroupPanel, field};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Default)]
|
||||||
|
struct Flags {
|
||||||
|
private: bool,
|
||||||
|
restricted: bool,
|
||||||
|
hidden: bool,
|
||||||
|
closed: bool,
|
||||||
|
livekit: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Flags {
|
||||||
|
fn of(metadata: &GroupMetadata) -> Self {
|
||||||
|
Self {
|
||||||
|
private: metadata.private,
|
||||||
|
restricted: metadata.restricted,
|
||||||
|
hidden: metadata.hidden,
|
||||||
|
closed: metadata.closed,
|
||||||
|
livekit: metadata.livekit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn open(
|
||||||
|
group: WeakEntity<Group>,
|
||||||
|
panel: WeakEntity<GroupPanel>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) {
|
||||||
|
let base = group
|
||||||
|
.read_with(cx, |group, _cx| group.metadata().cloned())
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let name = cx.new(|cx| {
|
||||||
|
InputState::new(window, cx)
|
||||||
|
.placeholder("Group name")
|
||||||
|
.default_value(base.name.clone().unwrap_or_default())
|
||||||
|
});
|
||||||
|
|
||||||
|
let about = cx.new(|cx| {
|
||||||
|
InputState::new(window, cx)
|
||||||
|
.placeholder("About")
|
||||||
|
.default_value(base.about.clone().unwrap_or_default())
|
||||||
|
});
|
||||||
|
|
||||||
|
let picture = cx.new(|cx| {
|
||||||
|
InputState::new(window, cx)
|
||||||
|
.placeholder("https://example.com/picture.png")
|
||||||
|
.default_value(base.picture.clone().unwrap_or_default())
|
||||||
|
});
|
||||||
|
|
||||||
|
let flags = cx.new(|_cx| Flags::of(&base));
|
||||||
|
|
||||||
|
window.open_dialog(cx, move |this, _window, cx| {
|
||||||
|
let current = *flags.read(cx);
|
||||||
|
|
||||||
|
this.width(px(420.))
|
||||||
|
.confirm()
|
||||||
|
.title("Edit group")
|
||||||
|
.button_props(
|
||||||
|
DialogButtonProps::default()
|
||||||
|
.cancel_text("Cancel")
|
||||||
|
.ok_text("Save"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_3()
|
||||||
|
.child(field("Name", &name, cx))
|
||||||
|
.child(field("About", &about, cx))
|
||||||
|
.child(field("Picture", &picture, cx))
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(
|
||||||
|
Switch::new("private")
|
||||||
|
.label("Private")
|
||||||
|
.description("Only members can read messages.")
|
||||||
|
.checked(current.private)
|
||||||
|
.on_click(toggle(&flags, |flags| &mut flags.private)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Switch::new("restricted")
|
||||||
|
.label("Restricted")
|
||||||
|
.description("Only members can send messages.")
|
||||||
|
.checked(current.restricted)
|
||||||
|
.on_click(toggle(&flags, |flags| &mut flags.restricted)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Switch::new("hidden")
|
||||||
|
.label("Hidden")
|
||||||
|
.description("Hide the group from non-members.")
|
||||||
|
.checked(current.hidden)
|
||||||
|
.on_click(toggle(&flags, |flags| &mut flags.hidden)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Switch::new("closed")
|
||||||
|
.label("Closed")
|
||||||
|
.description("Ignore join requests.")
|
||||||
|
.checked(current.closed)
|
||||||
|
.on_click(toggle(&flags, |flags| &mut flags.closed)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Switch::new("livekit")
|
||||||
|
.label("LiveKit")
|
||||||
|
.description("Announce audio and video support.")
|
||||||
|
.checked(current.livekit)
|
||||||
|
.on_click(toggle(&flags, |flags| &mut flags.livekit)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.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| {
|
||||||
|
let mut next = group
|
||||||
|
.read_with(cx, |group, _cx| group.metadata().cloned())
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
next.name = value(&name, cx);
|
||||||
|
next.about = value(&about, cx);
|
||||||
|
next.picture = value(&picture, cx);
|
||||||
|
|
||||||
|
let current = flags.read(cx);
|
||||||
|
next.private = current.private;
|
||||||
|
next.restricted = current.restricted;
|
||||||
|
next.hidden = current.hidden;
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle(
|
||||||
|
flags: &Entity<Flags>,
|
||||||
|
select: fn(&mut Flags) -> &mut bool,
|
||||||
|
) -> impl Fn(&bool, &mut Window, &mut App) + 'static {
|
||||||
|
let flags = flags.clone();
|
||||||
|
|
||||||
|
move |next, _window, cx| {
|
||||||
|
flags.update(cx, |flags, cx| {
|
||||||
|
*select(flags) = *next;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value(input: &Entity<InputState>, cx: &App) -> Option<String> {
|
||||||
|
let value = input.read(cx).value().trim().to_owned();
|
||||||
|
|
||||||
|
(!value.is_empty()).then_some(value)
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
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::{GroupPanel, field};
|
||||||
|
|
||||||
|
pub(crate) fn open(
|
||||||
|
group: WeakEntity<Group>,
|
||||||
|
panel: WeakEntity<GroupPanel>,
|
||||||
|
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 panel = panel.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, &panel, 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 panel = panel.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();
|
||||||
|
|
||||||
|
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
||||||
|
panel.put_user(public_key, roles, window, cx);
|
||||||
|
}) {
|
||||||
|
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],
|
||||||
|
panel: &WeakEntity<GroupPanel>,
|
||||||
|
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 panel = panel.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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.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()
|
||||||
|
}
|
||||||
@@ -302,17 +302,19 @@ Validation: same commands; manually delete a message, pin and unpin, leave and r
|
|||||||
|
|
||||||
The remaining moderation surface, all through the calls the backend already exposes.
|
The remaining moderation surface, all through the calls the backend already exposes.
|
||||||
|
|
||||||
- **Edit metadata** — a dialog started from `group.metadata().cloned().unwrap_or_default()`:
|
- **Edit metadata** — a dialog opened from the panel header's `Manage` menu, started from
|
||||||
name, about, picture, the flags. `children` is preserved as-is — `edit_metadata` re-emits
|
`group.metadata().cloned().unwrap_or_default()`: name, about, picture, the flags. `children`
|
||||||
the full list, and a `9002` that drops one is rejected by the relay.
|
is preserved as-is — `edit_metadata` re-emits the full list, and a `9002` that drops one is
|
||||||
|
rejected by the relay.
|
||||||
- **Invite** — `create_invite(code, cx)` with an input or generated code, then
|
- **Invite** — `create_invite(code, cx)` with an input or generated code, then
|
||||||
`metadata.naddr(relay, Some(code))` copied to the clipboard (`naddr` needs parsed metadata;
|
`metadata.naddr(relay, Some(code))` copied to the clipboard (`naddr` needs parsed metadata;
|
||||||
a group whose relay publishes no `39000` cannot mint a link).
|
a group whose relay publishes no `39000` cannot mint a link).
|
||||||
- **Users and roles** — add by `npub` → `put_user(pubkey, roles, cx)`, remove →
|
- **Users and roles** — add by `npub` → `put_user(pubkey, roles, cx)`, remove →
|
||||||
`remove_user(pubkey, cx)`; role choices come from `Group::roles()`. The relay is the
|
`remove_user(pubkey, cx)`; role choices come from `Group::roles()`. The relay is the
|
||||||
authority for both the roles it supports and who may call these.
|
authority for both the roles it supports and who may call these.
|
||||||
- **React** — the row menu sends `send_reaction(target, emoji, cx)`; a kind `7` row appears
|
- **React** — a `React` submenu in the same row menu sends `send_reaction(target, emoji, cx)`;
|
||||||
inline (that is the current backend behaviour).
|
a kind `7` row appears inline (that is the current backend behaviour). Unlike the items above
|
||||||
|
it is not admin-only, so the menu now exists for every member.
|
||||||
|
|
||||||
Validation: same commands; manually exercise each write against a relay that grants the
|
Validation: same commands; manually exercise each write against a relay that grants the
|
||||||
account admin, and confirm the refusal path shows the relay's message.
|
account admin, and confirm the refusal path shows the relay's message.
|
||||||
@@ -376,3 +378,7 @@ this repo; `coop_web` check is the proxy.
|
|||||||
- The fork banner's button joins the target group rather than opening it: the panel holds no
|
- The fork banner's button joins the target group rather than opening it: the panel holds no
|
||||||
dock handle and the plan's call is `join_key` alone; the joined group then appears in the
|
dock handle and the plan's call is `join_key` alone; the joined group then appears in the
|
||||||
sidebar.
|
sidebar.
|
||||||
|
- The admin writes sit behind a `Manage` menu in the panel header, shown only while the current
|
||||||
|
user is in `Group::admins()`; the details dialog stays read-only. The message row menu is now
|
||||||
|
built for every member — `React` is not moderation — and only its `Pin`/`Delete` items are
|
||||||
|
gated on being an admin.
|
||||||
|
|||||||
+6
-4
@@ -569,12 +569,14 @@ before they reach the consumer, so a replay cannot double-hydrate.
|
|||||||
|
|
||||||
## Not wired up yet
|
## Not wired up yet
|
||||||
|
|
||||||
- **Administration is partly built.** The sidebar's `Groups` tab lists
|
- **Administration is built on the client side.** The sidebar's `Groups` tab lists
|
||||||
`GroupsRegistry::groups()`, opens a `GroupPanel`, and offers the new-group and
|
`GroupsRegistry::groups()`, opens a `GroupPanel`, and offers the new-group and
|
||||||
join dialogs; `crates/workspace/src/panels/browse.rs` browses a relay and joins
|
join dialogs; `crates/workspace/src/panels/browse.rs` browses a relay and joins
|
||||||
from a result. The panel now has a read-only details dialog, message delete and
|
from a result. The panel has a read-only details dialog, a header `Manage` menu
|
||||||
pin/unpin for admins, a pins strip, a fork banner, and leave. Editing metadata,
|
(edit metadata, invite link, members and roles) shown to admins, a message menu
|
||||||
invites, and adding or removing users and roles are still to be built.
|
with reactions and admin pin/delete, a pins strip, a fork banner, and leave.
|
||||||
|
What a given account may actually do is the relay's call; the client shows the
|
||||||
|
refusal verbatim.
|
||||||
- **Remote deletions are not folded in.** A `9005` from another admin never reaches the
|
- **Remote deletions are not folded in.** A `9005` from another admin never reaches the
|
||||||
standing REQ, so only our own accepted deletes evict the row.
|
standing REQ, so only our own accepted deletes evict the row.
|
||||||
- **The LiveKit response shape is a guess.** The NIP names no fields, so
|
- **The LiveKit response shape is a guess.** The NIP names no fields, so
|
||||||
|
|||||||
Reference in New Issue
Block a user