Compare commits
3
Commits
857795a92e
...
65ea654745
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65ea654745 | ||
|
|
a93be19e81 | ||
|
|
75b0f49d9e |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M12.75 11.2499V8.24994L17.3358 3.66416C18.1168 2.88311 19.3832 2.88311 20.1642 3.66416L20.3358 3.83573C21.1168 4.61678 21.1168 5.88311 20.3358 6.66416L15.75 11.2499H12.75Z" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M11.25 3.75H6.95C5.8299 3.75 5.26984 3.75 4.84202 3.96799C4.46569 4.15973 4.15973 4.46569 3.96799 4.84202C3.75 5.26984 3.75 5.8299 3.75 6.95V17.05C3.75 18.1701 3.75 18.7302 3.96799 19.158C4.15973 19.5343 4.46569 19.8403 4.84202 20.032C5.26984 20.25 5.8299 20.25 6.95 20.25H17.05C18.1701 20.25 18.7302 20.25 19.158 20.032C19.5343 19.8403 19.8403 19.5343 20.032 19.158C20.25 18.7302 20.25 18.1701 20.25 17.05V12.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> </svg>
|
||||||
|
After Width: | Height: | Size: 857 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M20.25 12L9 12M20.25 12L15.75 16.5M20.25 12L15.75 7.5M11.25 20.25H5.75C4.64543 20.25 3.75 19.3546 3.75 18.25L3.75 5.75C3.75 4.64543 4.64543 3.75 5.75 3.75L11.25 3.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> </svg>
|
||||||
|
After Width: | Height: | Size: 346 B |
@@ -1,3 +1 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"> <path d="M11.25 20.25H5.75C4.64543 20.25 3.75 19.3546 3.75 18.25V5.75C3.75 4.64543 4.64543 3.75 5.75 3.75H18.25C19.3546 3.75 20.25 4.64543 20.25 5.75V11.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M3.75 16.25L6.58579 13.4142C7.36683 12.6332 8.63317 12.6332 9.41421 13.4142L12 16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M14.5 11.5C15.6046 11.5 16.5 10.6046 16.5 9.5C16.5 8.39543 15.6046 7.5 14.5 7.5C13.3954 7.5 12.5 8.39543 12.5 9.5C12.5 10.6046 13.3954 11.5 14.5 11.5Z" stroke="currentColor" stroke-width="1.5"/><path d="M15.75 18.5H21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M18.5 15.75V21.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> </svg>
|
||||||
<path d="M12 19.25V13L14.5 15.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 13L9.5 15.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.375 19.25H4.75C3.64543 19.25 2.75 18.3546 2.75 17.25V5.75C2.75 4.64543 3.64543 3.75 4.75 3.75H8.92963C9.59834 3.75 10.2228 4.0842 10.5937 4.6406L11.7031 6.3047C11.8886 6.5829 12.2008 6.75 12.5352 6.75H19.25C20.3546 6.75 21.25 7.64543 21.25 8.75V17.25C21.25 18.3546 20.3546 19.25 19.25 19.25H16.625" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 718 B After Width: | Height: | Size: 849 B |
@@ -145,6 +145,10 @@ impl Group {
|
|||||||
.map(SharedString::from)
|
.map(SharedString::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn total_members(&self) -> usize {
|
||||||
|
self.members.len()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn members(&self) -> &GroupMembers {
|
pub fn members(&self) -> &GroupMembers {
|
||||||
&self.members
|
&self.members
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ impl GroupId {
|
|||||||
pub fn timeline_filter(&self, limit: usize) -> Filter {
|
pub fn timeline_filter(&self, limit: usize) -> Filter {
|
||||||
Filter::new()
|
Filter::new()
|
||||||
.custom_tag(SingleLetterTag::LOWERCASE_H, self.0.clone())
|
.custom_tag(SingleLetterTag::LOWERCASE_H, self.0.clone())
|
||||||
|
.kinds(RENDER_KINDS)
|
||||||
|
.kinds(ACTIVITY_KINDS)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,6 +546,15 @@ impl GroupMembers {
|
|||||||
pub fn iter(&self) -> impl Iterator<Item = &PublicKey> {
|
pub fn iter(&self) -> impl Iterator<Item = &PublicKey> {
|
||||||
self.0.iter()
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -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 nostr_sdk::prelude::SecretKey;
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
use ui::dialog::DialogButtonProps;
|
use ui::dialog::DialogButtonProps;
|
||||||
use ui::input::InputState;
|
use ui::input::InputState;
|
||||||
|
use ui::notification::Notification;
|
||||||
use ui::{WindowExtension, v_flex};
|
use ui::{WindowExtension, v_flex};
|
||||||
|
|
||||||
use crate::{GroupPanel, field};
|
use crate::field;
|
||||||
|
|
||||||
const CODE_LEN: usize = 12;
|
const CODE_LEN: usize = 12;
|
||||||
|
|
||||||
pub(crate) fn open(panel: WeakEntity<GroupPanel>, window: &mut Window, cx: &mut App) {
|
pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
|
||||||
let code = SecretKey::generate().to_secret_hex();
|
let code = SecretKey::generate().to_secret_hex();
|
||||||
let code: String = code.chars().take(CODE_LEN).collect();
|
let code: String = code.chars().take(CODE_LEN).collect();
|
||||||
|
|
||||||
@@ -20,7 +26,7 @@ pub(crate) fn open(panel: WeakEntity<GroupPanel>, window: &mut Window, cx: &mut
|
|||||||
});
|
});
|
||||||
|
|
||||||
window.open_dialog(cx, move |this, _window, cx| {
|
window.open_dialog(cx, move |this, _window, cx| {
|
||||||
let panel = panel.clone();
|
let group = group.clone();
|
||||||
let input = input.clone();
|
let input = input.clone();
|
||||||
|
|
||||||
this.width(px(420.))
|
this.width(px(420.))
|
||||||
@@ -40,17 +46,64 @@ pub(crate) fn open(panel: WeakEntity<GroupPanel>, window: &mut Window, cx: &mut
|
|||||||
)
|
)
|
||||||
.child(field("Code", &input, cx)),
|
.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();
|
let code = input.read(cx).value().trim().to_owned();
|
||||||
|
|
||||||
if code.is_empty() {
|
if code.is_empty() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
let link = group
|
||||||
panel.create_invite(code, window, cx);
|
.read_with(cx, |group, _cx| {
|
||||||
}) {
|
group.metadata().and_then(|metadata| {
|
||||||
log::warn!("nip29: creating an invite failed: {error}");
|
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
|
true
|
||||||
|
|||||||
+156
-286
@@ -4,12 +4,12 @@ use anyhow::Result;
|
|||||||
use chat_ui::text::rendered_text;
|
use chat_ui::text::rendered_text;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Action, AnyElement, App, AppContext, ClipboardItem, Context, Div, Entity, EventEmitter,
|
Action, AnyElement, App, AppContext, Context, Div, Entity, EventEmitter, FocusHandle,
|
||||||
FocusHandle, Focusable, FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState,
|
Focusable, FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement,
|
||||||
ParentElement, PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity,
|
PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div,
|
||||||
Window, div, list, px,
|
list, px,
|
||||||
};
|
};
|
||||||
use nip29::{Group, GroupEvent, GroupMetadata, GroupsRegistry, Membership, Pin, Row};
|
use nip29::{Group, GroupEvent, GroupsRegistry, Membership, Pin, Row};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use person::PersonRegistry;
|
use person::PersonRegistry;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -23,7 +23,6 @@ use ui::dialog::DialogButtonProps;
|
|||||||
use ui::dock::{Panel, PanelEvent};
|
use ui::dock::{Panel, PanelEvent};
|
||||||
use ui::input::{Input, InputEvent, InputState, 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;
|
||||||
@@ -33,7 +32,6 @@ mod details;
|
|||||||
mod invite;
|
mod invite;
|
||||||
mod message;
|
mod message;
|
||||||
mod metadata;
|
mod metadata;
|
||||||
mod users;
|
|
||||||
|
|
||||||
/// Ask the shell to show or hide the sidebar's information for a group.
|
/// Ask the shell to show or hide the sidebar's information for a group.
|
||||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||||
@@ -159,6 +157,132 @@ 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<Group>, 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 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("group-invite")
|
||||||
|
.icon(IconName::Invite)
|
||||||
|
.tooltip("Invite people")
|
||||||
|
.ghost()
|
||||||
|
.small()
|
||||||
|
.on_click(move |_event, window, cx| {
|
||||||
|
invite::open(invite.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<Group>, 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
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
.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 {
|
pub struct GroupPanel {
|
||||||
id: SharedString,
|
id: SharedString,
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
@@ -409,121 +533,6 @@ 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(
|
fn react(
|
||||||
&mut self,
|
&mut self,
|
||||||
group: WeakEntity<Group>,
|
group: WeakEntity<Group>,
|
||||||
@@ -599,169 +608,6 @@ impl GroupPanel {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn confirm_leave(group: WeakEntity<Group>, 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<Self>) -> 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<Self>) -> AnyElement {
|
fn render_banner(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let Some(group) = self.group.upgrade() else {
|
let Some(group) = self.group.upgrade() else {
|
||||||
return div().into_any_element();
|
return div().into_any_element();
|
||||||
@@ -1024,6 +870,15 @@ impl GroupPanel {
|
|||||||
supports_text && writable
|
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()
|
v_flex()
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
.w_full()
|
.w_full()
|
||||||
@@ -1034,6 +889,22 @@ impl GroupPanel {
|
|||||||
h_flex()
|
h_flex()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.items_end()
|
.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(
|
.child(
|
||||||
Button::new("upload")
|
Button::new("upload")
|
||||||
.icon(IconName::Upload)
|
.icon(IconName::Upload)
|
||||||
@@ -1147,7 +1018,6 @@ impl Render for GroupPanel {
|
|||||||
v_flex()
|
v_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
.min_w_0()
|
.min_w_0()
|
||||||
.child(self.render_header(cx))
|
|
||||||
.child(self.render_banner(cx))
|
.child(self.render_banner(cx))
|
||||||
.child(self.render_pins(cx))
|
.child(self.render_pins(cx))
|
||||||
.child(
|
.child(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use ui::input::InputState;
|
|||||||
use ui::switch::Switch;
|
use ui::switch::Switch;
|
||||||
use ui::{WindowExtension, v_flex};
|
use ui::{WindowExtension, v_flex};
|
||||||
|
|
||||||
use crate::{GroupPanel, field};
|
use crate::field;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Default)]
|
#[derive(Clone, Copy, Default)]
|
||||||
struct Flags {
|
struct Flags {
|
||||||
@@ -28,12 +28,7 @@ impl Flags {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn open(
|
pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
|
||||||
group: WeakEntity<Group>,
|
|
||||||
panel: WeakEntity<GroupPanel>,
|
|
||||||
window: &mut Window,
|
|
||||||
cx: &mut App,
|
|
||||||
) {
|
|
||||||
let base = group
|
let base = group
|
||||||
.read_with(cx, |group, _cx| group.metadata().cloned())
|
.read_with(cx, |group, _cx| group.metadata().cloned())
|
||||||
.ok()
|
.ok()
|
||||||
@@ -119,13 +114,12 @@ pub(crate) fn open(
|
|||||||
)
|
)
|
||||||
.on_ok({
|
.on_ok({
|
||||||
let group = group.clone();
|
let group = group.clone();
|
||||||
let panel = panel.clone();
|
|
||||||
let name = name.clone();
|
let name = name.clone();
|
||||||
let about = about.clone();
|
let about = about.clone();
|
||||||
let picture = picture.clone();
|
let picture = picture.clone();
|
||||||
let flags = flags.clone();
|
let flags = flags.clone();
|
||||||
|
|
||||||
move |_event, _window, cx| {
|
move |_event, window, cx| {
|
||||||
let mut next = group
|
let mut next = group
|
||||||
.read_with(cx, |group, _cx| group.metadata().cloned())
|
.read_with(cx, |group, _cx| group.metadata().cloned())
|
||||||
.ok()
|
.ok()
|
||||||
@@ -143,10 +137,9 @@ pub(crate) fn open(
|
|||||||
next.closed = current.closed;
|
next.closed = current.closed;
|
||||||
next.livekit = current.livekit;
|
next.livekit = current.livekit;
|
||||||
|
|
||||||
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
|
match group.update(cx, |group, cx| group.edit_metadata(&next, cx)) {
|
||||||
panel.edit_metadata(next, window, cx);
|
Ok(task) => crate::report(window, cx, task),
|
||||||
}) {
|
Err(error) => log::warn!("nip29: editing the group failed: {error}"),
|
||||||
log::warn!("nip29: editing the group failed: {error}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
true
|
true
|
||||||
|
|||||||
@@ -1,209 +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::{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()
|
|
||||||
}
|
|
||||||
@@ -50,6 +50,7 @@ pub enum IconName {
|
|||||||
Link,
|
Link,
|
||||||
Loader,
|
Loader,
|
||||||
Lock,
|
Lock,
|
||||||
|
Logout,
|
||||||
Message,
|
Message,
|
||||||
Moon,
|
Moon,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -72,6 +73,7 @@ pub enum IconName {
|
|||||||
UserKey,
|
UserKey,
|
||||||
Upload,
|
Upload,
|
||||||
Usb,
|
Usb,
|
||||||
|
Edit,
|
||||||
PanelLeft,
|
PanelLeft,
|
||||||
PanelLeftOpen,
|
PanelLeftOpen,
|
||||||
PanelRight,
|
PanelRight,
|
||||||
@@ -129,6 +131,7 @@ impl IconNamed for IconName {
|
|||||||
Self::Link => "icons/link.svg",
|
Self::Link => "icons/link.svg",
|
||||||
Self::Loader => "icons/loader.svg",
|
Self::Loader => "icons/loader.svg",
|
||||||
Self::Lock => "icons/lock.svg",
|
Self::Lock => "icons/lock.svg",
|
||||||
|
Self::Logout => "icons/logout.svg",
|
||||||
Self::Message => "icons/message.svg",
|
Self::Message => "icons/message.svg",
|
||||||
Self::Moon => "icons/moon.svg",
|
Self::Moon => "icons/moon.svg",
|
||||||
Self::Plus => "icons/plus.svg",
|
Self::Plus => "icons/plus.svg",
|
||||||
@@ -158,6 +161,7 @@ impl IconNamed for IconName {
|
|||||||
Self::PanelBottom => "icons/panel-bottom.svg",
|
Self::PanelBottom => "icons/panel-bottom.svg",
|
||||||
Self::PanelBottomOpen => "icons/panel-bottom-open.svg",
|
Self::PanelBottomOpen => "icons/panel-bottom-open.svg",
|
||||||
Self::PaperPlaneFill => "icons/paper-plane-fill.svg",
|
Self::PaperPlaneFill => "icons/paper-plane-fill.svg",
|
||||||
|
Self::Edit => "icons/edit.svg",
|
||||||
Self::Warning => "icons/warning.svg",
|
Self::Warning => "icons/warning.svg",
|
||||||
Self::WindowClose => "icons/window-close.svg",
|
Self::WindowClose => "icons/window-close.svg",
|
||||||
Self::WindowMaximize => "icons/window-maximize.svg",
|
Self::WindowMaximize => "icons/window-maximize.svg",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use crate::Selectable;
|
|||||||
use crate::avatar::Avatar;
|
use crate::avatar::Avatar;
|
||||||
use crate::button::Button;
|
use crate::button::Button;
|
||||||
use crate::menu::PopupMenu;
|
use crate::menu::PopupMenu;
|
||||||
|
use crate::nav::Nav;
|
||||||
use crate::popover::{Popover, PopoverState};
|
use crate::popover::{Popover, PopoverState};
|
||||||
|
|
||||||
/// Builds the items of a popup menu on each render.
|
/// 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 Avatar {}
|
||||||
|
|
||||||
|
impl DropdownMenu for Nav {}
|
||||||
|
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct DropdownMenuPopover<T: Selectable + IntoElement + 'static> {
|
pub struct DropdownMenuPopover<T: Selectable + IntoElement + 'static> {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
|
|||||||
+26
-5
@@ -2,9 +2,9 @@ use std::rc::Rc;
|
|||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, ClickEvent, ElementId, InteractiveElement, IntoElement, ParentElement,
|
AnyElement, App, ClickEvent, ElementId, InteractiveElement, Interactivity, IntoElement,
|
||||||
RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Window,
|
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement,
|
||||||
div,
|
Styled, Window, div,
|
||||||
};
|
};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
@@ -14,28 +14,43 @@ use crate::{Selectable, StyledExt, h_flex, v_flex};
|
|||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct Nav {
|
pub struct Nav {
|
||||||
id: ElementId,
|
id: ElementId,
|
||||||
|
interactivity: Interactivity,
|
||||||
style: StyleRefinement,
|
style: StyleRefinement,
|
||||||
prefix: Option<AnyElement>,
|
prefix: Option<AnyElement>,
|
||||||
label: SharedString,
|
label: SharedString,
|
||||||
suffix: Option<AnyElement>,
|
suffix: Option<AnyElement>,
|
||||||
selected: bool,
|
selected: bool,
|
||||||
|
clickable: bool,
|
||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity)]
|
||||||
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Nav {
|
impl Nav {
|
||||||
pub fn new(id: impl Into<ElementId>) -> Self {
|
pub fn new(id: impl Into<ElementId>) -> Self {
|
||||||
|
let id = id.into();
|
||||||
|
let mut interactivity = Interactivity::default();
|
||||||
|
interactivity.element_id = Some(id.clone());
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
id: id.into(),
|
id,
|
||||||
|
interactivity,
|
||||||
style: StyleRefinement::default(),
|
style: StyleRefinement::default(),
|
||||||
prefix: None,
|
prefix: None,
|
||||||
label: SharedString::default(),
|
label: SharedString::default(),
|
||||||
suffix: None,
|
suffix: None,
|
||||||
selected: false,
|
selected: false,
|
||||||
|
clickable: false,
|
||||||
on_click: None,
|
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.
|
/// Sets the element shown before the label, such as an avatar or icon.
|
||||||
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
|
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
|
||||||
self.prefix = Some(prefix.into_any_element());
|
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 {
|
impl Styled for Nav {
|
||||||
fn style(&mut self) -> &mut StyleRefinement {
|
fn style(&mut self) -> &mut StyleRefinement {
|
||||||
&mut self.style
|
&mut self.style
|
||||||
@@ -82,7 +103,7 @@ impl Styled for Nav {
|
|||||||
|
|
||||||
impl RenderOnce for Nav {
|
impl RenderOnce for Nav {
|
||||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
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(
|
v_flex().w_full().h_10().child(
|
||||||
h_flex()
|
h_flex()
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
|
|||||||
use common::TimestampExt;
|
use common::TimestampExt;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, AppContext, Context, Div, Entity, EventEmitter, FocusHandle, Focusable,
|
AnyElement, App, AppContext, ClipboardItem, Context, Div, Entity, EventEmitter, FocusHandle,
|
||||||
InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString, Stateful,
|
Focusable, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString,
|
||||||
Styled, StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, div, img,
|
Stateful, Styled, StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window,
|
||||||
px, retain_all, uniform_list,
|
div, img, px, relative, retain_all, uniform_list,
|
||||||
};
|
};
|
||||||
use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry, Membership};
|
use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry, Membership};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
@@ -922,9 +922,15 @@ impl Sidebar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_group(&mut self, group: Entity<Group>, cx: &mut Context<Self>) -> AnyElement {
|
fn render_group(&mut self, group: Entity<Group>, cx: &mut Context<Self>) -> AnyElement {
|
||||||
let (banner, name, seed, picture, 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 group = group.read(cx);
|
||||||
let admins = group.admins();
|
let admins = group.admins();
|
||||||
|
|
||||||
let mut admin_rows = Vec::new();
|
let mut admin_rows = Vec::new();
|
||||||
let mut member_rows = Vec::new();
|
let mut member_rows = Vec::new();
|
||||||
|
|
||||||
@@ -962,52 +968,58 @@ impl Sidebar {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let metadata = group.metadata();
|
|
||||||
|
|
||||||
(
|
(
|
||||||
metadata.and_then(|metadata| {
|
|
||||||
metadata.banner.clone().or_else(|| metadata.picture.clone())
|
|
||||||
}),
|
|
||||||
group.display_name(),
|
group.display_name(),
|
||||||
group.key().cache_tag(),
|
group.key().cache_tag(),
|
||||||
group.display_image(),
|
group.display_image(),
|
||||||
|
SharedString::from(format!("{} members", group.total_members())),
|
||||||
rows,
|
rows,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
let rows = Rc::new(rows);
|
let rows = Rc::new(rows);
|
||||||
|
let member_group = group.downgrade();
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.min_h_0()
|
.min_h_0()
|
||||||
.w_full()
|
.w_full()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.when_some(banner, |this, banner| {
|
|
||||||
this.child(
|
|
||||||
div().px_2().flex_shrink_0().child(
|
|
||||||
div()
|
|
||||||
.h_40()
|
|
||||||
.w_full()
|
|
||||||
.overflow_hidden()
|
|
||||||
.rounded(cx.theme().radius_lg)
|
|
||||||
.child(img(banner).size_full().object_fit(ObjectFit::Cover)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.px_2()
|
.px_2()
|
||||||
.gap_2()
|
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
|
.justify_between()
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.pl_0p5()
|
||||||
|
.gap_1()
|
||||||
|
.flex_shrink_1()
|
||||||
.child(Avatar::from_source(picture).seed(seed).small())
|
.child(Avatar::from_source(picture).seed(seed).small())
|
||||||
.child(
|
.child(
|
||||||
div()
|
v_flex()
|
||||||
.flex_1()
|
.flex_1()
|
||||||
.min_w_0()
|
.min_w_0()
|
||||||
.text_sm()
|
.child(
|
||||||
.font_medium()
|
div()
|
||||||
|
.text_xs()
|
||||||
.truncate()
|
.truncate()
|
||||||
|
.line_height(relative(1.2))
|
||||||
|
.font_semibold()
|
||||||
.child(name),
|
.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(
|
.child(
|
||||||
@@ -1019,7 +1031,13 @@ impl Sidebar {
|
|||||||
"group-rows",
|
"group-rows",
|
||||||
rows.len(),
|
rows.len(),
|
||||||
cx.processor(move |this, range, _window, cx| {
|
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)
|
.track_scroll(&self.group_scroll)
|
||||||
@@ -1035,6 +1053,8 @@ impl Sidebar {
|
|||||||
&self,
|
&self,
|
||||||
range: Range<usize>,
|
range: Range<usize>,
|
||||||
rows: &[GroupRow],
|
rows: &[GroupRow],
|
||||||
|
group: &WeakEntity<Group>,
|
||||||
|
admin: bool,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Vec<AnyElement> {
|
) -> Vec<AnyElement> {
|
||||||
rows.get(range)
|
rows.get(range)
|
||||||
@@ -1042,7 +1062,9 @@ impl Sidebar {
|
|||||||
.flatten()
|
.flatten()
|
||||||
.map(|row| match row {
|
.map(|row| match row {
|
||||||
GroupRow::Section(section) => self.section_row(*section, cx),
|
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()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -1079,16 +1101,22 @@ impl Sidebar {
|
|||||||
&self,
|
&self,
|
||||||
public_key: &PublicKey,
|
public_key: &PublicKey,
|
||||||
roles: &[String],
|
roles: &[String],
|
||||||
|
group: &WeakEntity<Group>,
|
||||||
|
admin: bool,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||||
let persons = PersonRegistry::global(cx);
|
let persons = PersonRegistry::global(cx);
|
||||||
let person = persons.read(cx).get(public_key, 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())
|
Nav::new(public_key.to_hex())
|
||||||
.label(person.name())
|
.label(person.name())
|
||||||
.text_sm()
|
.text_sm()
|
||||||
.font_medium()
|
.font_medium()
|
||||||
|
.clickable(true)
|
||||||
.when(!hide_avatar, |this| {
|
.when(!hide_avatar, |this| {
|
||||||
this.prefix(
|
this.prefix(
|
||||||
Avatar::new(person.avatar())
|
Avatar::new(person.avatar())
|
||||||
@@ -1106,10 +1134,29 @@ impl Sidebar {
|
|||||||
.child(SharedString::from(roles.join(", "))),
|
.child(SharedString::from(roles.join(", "))),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.on_click({
|
.dropdown_menu(move |menu, _window, _cx| {
|
||||||
let public_key = *public_key;
|
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()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-9
@@ -588,15 +588,17 @@ before they reach the consumer, so a replay cannot double-hydrate.
|
|||||||
- **Administration is built on the client side.** 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 has a read-only details dialog, a header `Manage` menu
|
from a result. The panel has a read-only details dialog, a message menu with
|
||||||
(edit metadata, invite link, members and roles) shown to admins, a message menu
|
reactions and admin pin/delete, a pins strip, a fork banner, and leave. Opening a
|
||||||
with reactions and admin pin/delete, a pins strip, a fork banner, and leave.
|
group also fills the sidebar with the group itself — banner (the `39000` `banner`
|
||||||
Opening a group also fills the sidebar with the group itself — banner (the
|
tag, falling back to its `picture`), avatar and name, a row of info / edit / invite /
|
||||||
`39000` `banner` tag, falling back to its `picture`), avatar and name, and the
|
leave buttons, and the collapsible `Admins`/`Members` lists. Clicking a member opens
|
||||||
collapsible `Admins`/`Members` lists. Members are clickable and open a profile
|
a dropdown menu (view profile, copy npub, and — for admins — remove from group).
|
||||||
dialog. The panel header shows the group's `about` instead of its name, plus a
|
There is no members-and-roles dialog: adding a member is done through the invite
|
||||||
button that shows or hides the sidebar's group information. What a given account
|
flow, and roles are read-only. The panel has no header: the group's `about` lives
|
||||||
may actually do is the relay's call; the client shows the refusal verbatim.
|
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
|
- **The composer uploads to the configured blossom server.** `GroupPanel`'s
|
||||||
paperclip uploads one file at a time, unencrypted, and appends its URL to the
|
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
|
message on send — the same plain-attachment path the chat composer uses when a
|
||||||
|
|||||||
Reference in New Issue
Block a user