feat: add group management to the sidebar

This commit is contained in:
2026-10-08 20:58:58 +07:00
parent 09fb58c5a4
commit 193df36dae
14 changed files with 299 additions and 923 deletions
+9
View File
@@ -226,6 +226,15 @@ impl Group {
self.dispatch(move |id, prev| id.remove_user(public_key, prev), cx)
}
pub fn put_user(
&mut self,
public_key: PublicKey,
roles: Vec<String>,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
self.dispatch(move |id, prev| id.put_user(public_key, &roles, prev), cx)
}
pub fn edit_metadata(
&mut self,
metadata: &GroupMetadata,
+15
View File
@@ -134,6 +134,21 @@ impl GroupId {
.tag_maybe(previous)
}
pub fn put_user(
&self,
public_key: PublicKey,
roles: &[String],
previous: Option<Tag>,
) -> EventBuilder {
EventBuilder::new(Kind::GroupPutUser, "")
.tag(self.h_tag())
.tag(Tag::custom(
"p",
std::iter::once(public_key.to_hex()).chain(roles.iter().cloned()),
))
.tag_maybe(previous)
}
pub fn edit_metadata(&self, metadata: &GroupMetadata, previous: Option<Tag>) -> EventBuilder {
EventBuilder::new(Kind::GroupEditMetadata, "")
.tags(self.metadata_tags(metadata))
+9 -62
View File
@@ -1,19 +1,17 @@
use gpui::{
AnyElement, App, IntoElement, ParentElement, SharedString, Styled, WeakEntity, Window, div, px,
relative,
};
use nip29::{Group, GroupMetadata};
use nostr_sdk::prelude::PublicKey;
use person::PersonRegistry;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::{Sizable, StyledExt, WindowExtension, h_flex, v_flex};
pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
window.open_dialog(cx, move |this, _window, cx| {
this.width(px(460.))
.max_h(px(600.))
this.max_h(px(600.))
.show_close(true)
.title("Group details")
.title("About Group")
.child(body(&group, cx))
});
}
@@ -89,21 +87,7 @@ fn body(group: &WeakEntity<Group>, cx: &App) -> AnyElement {
}
}
let admins = group.admins().entries();
if !admins.is_empty() {
body = body.child(section(
"Admins",
v_flex()
.gap_1()
.children(admins.iter().map(|(key, roles)| admin(key, roles, cx)))
.into_any_element(),
cx,
));
}
let roles = group.roles().roles();
if !roles.is_empty() {
body = body.child(section(
"Roles",
@@ -112,21 +96,7 @@ fn body(group: &WeakEntity<Group>, cx: &App) -> AnyElement {
));
}
let members: Vec<&PublicKey> = group.members().iter().collect();
if !members.is_empty() {
body = body.child(section(
"Members",
v_flex()
.gap_1()
.children(members.into_iter().map(|key| person(key, cx)))
.into_any_element(),
cx,
));
}
let pins = group.pins().pins();
if !pins.is_empty() {
body = body.child(section(
"Pinned",
@@ -138,7 +108,7 @@ fn body(group: &WeakEntity<Group>, cx: &App) -> AnyElement {
div()
.truncate()
.text_color(cx.theme().text_muted)
.child(crate::pin_label(group, pin))
.child(crate::util::pin_label(group, pin))
.into_any_element()
})
.collect::<Vec<_>>(),
@@ -209,38 +179,15 @@ fn wrap(items: impl IntoIterator<Item = AnyElement>) -> AnyElement {
.into_any_element()
}
fn admin(public_key: &PublicKey, roles: &[String], cx: &App) -> AnyElement {
h_flex()
.justify_between()
.gap_2()
.child(person(public_key, cx))
.children(roles.iter().map(|role| chip(role.clone(), cx)))
.into_any_element()
}
fn person(public_key: &PublicKey, cx: &App) -> AnyElement {
let persons = PersonRegistry::global(cx);
let person = persons.read(cx).get(public_key, cx);
h_flex()
.gap_2()
.min_w_0()
.child(
Avatar::new(person.avatar())
.seed(person.avatar_seed())
.xsmall(),
)
.child(div().truncate().child(person.name()))
.into_any_element()
}
fn chip(label: impl Into<SharedString>, cx: &App) -> AnyElement {
div()
.px_1p5()
.py_0p5()
.px_2()
.py_1()
.rounded(cx.theme().radius)
.bg(cx.theme().element_background)
.bg(cx.theme().secondary_background)
.text_color(cx.theme().secondary_foreground)
.text_xs()
.line_height(relative(1.))
.child(label.into())
.into_any_element()
}
+1 -1
View File
@@ -11,7 +11,7 @@ use ui::input::InputState;
use ui::notification::Notification;
use ui::{WindowExtension, v_flex};
use crate::field;
use crate::util::field;
const CODE_LEN: usize = 12;
+37 -83
View File
@@ -4,8 +4,8 @@ use anyhow::Result;
use chat_ui::text::rendered_text;
use gpui::prelude::FluentBuilder;
use gpui::{
Action, AnyElement, App, AppContext, Context, Div, Entity, EventEmitter, FocusHandle,
Focusable, FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement,
Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement,
PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div,
list, px,
};
@@ -21,17 +21,19 @@ use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariant, ButtonVariants};
use ui::dialog::DialogButtonProps;
use ui::dock::{Panel, PanelEvent};
use ui::input::{Input, InputEvent, InputState, Textarea, TextareaState};
use ui::input::{InputEvent, Textarea, TextareaState};
use ui::markdown::RenderedText;
use ui::message::WelcomeMessage;
use ui::notification::Notification;
use ui::scroll::Scrollbar;
use ui::{Disableable, Icon, IconName, Sizable, WindowExtension, h_flex, v_flex};
use util::{attachment_name, display_name, opens_run, pin_label, report};
mod details;
mod invite;
mod message;
mod metadata;
mod util;
/// Ask the shell to show the sidebar's information for a group.
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
@@ -49,8 +51,8 @@ pub struct HideGroupMeta {
pub group: String,
}
const ADMIN_ROLE: &str = "admin";
const LOAD_OLDER_THRESHOLD: usize = 20;
const RUN_WINDOW_SECS: u64 = 300;
enum Notice {
Relay(String),
@@ -69,46 +71,6 @@ impl Notice {
}
}
fn display_name(group: &Group) -> SharedString {
group
.metadata()
.and_then(|metadata| metadata.name())
.map_or_else(
|| SharedString::from(group.key().id().as_str()),
SharedString::from,
)
}
fn attachment_name(url: &Url) -> SharedString {
url.path_segments()
.and_then(|mut segments| segments.next_back())
.filter(|name| !name.is_empty())
.map_or_else(
|| SharedString::from(url.as_str()),
|name| SharedString::from(name.to_owned()),
)
}
fn opens_run(rows: &[Row], index: usize) -> bool {
let (Some(current), Some(previous)) = (
rows.get(index),
index.checked_sub(1).and_then(|index| rows.get(index)),
) else {
return true;
};
if !matches!((current, previous), (Row::Message(_), Row::Message(_))) {
return true;
}
current.author() != previous.author()
|| current
.created_at()
.as_secs()
.saturating_sub(previous.created_at().as_secs())
> RUN_WINDOW_SECS
}
pub fn init(group: Entity<Group>, window: &mut Window, cx: &mut App) -> Entity<GroupPanel> {
let key = group.read(cx).key().clone();
let groups = GroupsRegistry::global(cx);
@@ -120,33 +82,6 @@ pub fn init(group: Entity<Group>, window: &mut Window, cx: &mut App) -> Entity<G
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 {
match pin {
Pin::Address(coordinate) => SharedString::from(format!("{coordinate}")),
Pin::Event(id) => {
let snippet = group
.message(*id)
.and_then(|message| message.content.lines().next())
.map(str::trim)
.filter(|line| !line.is_empty());
match snippet {
Some(snippet) => SharedString::from(snippet.chars().take(48).collect::<String>()),
None => SharedString::from(id.to_hex().chars().take(8).collect::<String>()),
}
}
}
}
/// 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);
@@ -259,18 +194,37 @@ pub fn remove_member(
}
}
/// 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();
/// Grant a member the admin role and surface any relay error as a notification.
pub fn make_admin(
group: WeakEntity<Group>,
public_key: PublicKey,
window: &mut Window,
cx: &mut App,
) {
put_user(group, public_key, vec![ADMIN_ROLE.to_owned()], window, cx);
}
/// Revoke a member's roles, returning them to a regular member.
pub fn remove_admin(
group: WeakEntity<Group>,
public_key: PublicKey,
window: &mut Window,
cx: &mut App,
) {
put_user(group, public_key, Vec::new(), window, cx);
}
fn put_user(
group: WeakEntity<Group>,
public_key: PublicKey,
roles: Vec<String>,
window: &mut Window,
cx: &mut App,
) {
match group.update(cx, |group, cx| group.put_user(public_key, roles, cx)) {
Ok(task) => report(window, cx, task),
Err(error) => log::warn!("nip29: updating a member's roles failed: {error}"),
}
}
pub struct GroupPanel {
+5 -6
View File
@@ -1,11 +1,11 @@
use gpui::{App, AppContext, Entity, ParentElement, Styled, WeakEntity, Window, px};
use gpui::{App, AppContext, Entity, ParentElement, Styled, WeakEntity, Window};
use nip29::{Group, GroupMetadata};
use ui::dialog::DialogButtonProps;
use ui::input::InputState;
use ui::switch::Switch;
use ui::{WindowExtension, v_flex};
use crate::field;
use crate::util::field;
#[derive(Clone, Copy, Default)]
struct Flags {
@@ -58,9 +58,8 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
window.open_dialog(cx, move |this, _window, cx| {
let current = *flags.read(cx);
this.width(px(420.))
.confirm()
.title("Edit group")
this.confirm()
.title("Edit Group")
.button_props(
DialogButtonProps::default()
.cancel_text("Cancel")
@@ -138,7 +137,7 @@ pub(crate) fn open(group: WeakEntity<Group>, window: &mut Window, cx: &mut App)
next.livekit = current.livekit;
match group.update(cx, |group, cx| group.edit_metadata(&next, cx)) {
Ok(task) => crate::report(window, cx, task),
Ok(task) => crate::util::report(window, cx, task),
Err(error) => log::warn!("nip29: editing the group failed: {error}"),
}
+91
View File
@@ -0,0 +1,91 @@
use anyhow::Result;
use gpui::{App, Div, Entity, ParentElement, SharedString, Styled, Task, Window};
use nip29::{Group, Pin, Row};
use nostr_sdk::prelude::Url;
use theme::ActiveTheme;
use ui::input::{Input, InputState};
use ui::notification::Notification;
use ui::{WindowExtension, v_flex};
const RUN_WINDOW_SECS: u64 = 300;
pub(crate) fn display_name(group: &Group) -> SharedString {
group
.metadata()
.and_then(|metadata| metadata.name())
.map_or_else(
|| SharedString::from(group.key().id().as_str()),
SharedString::from,
)
}
pub(crate) fn attachment_name(url: &Url) -> SharedString {
url.path_segments()
.and_then(|mut segments| segments.next_back())
.filter(|name| !name.is_empty())
.map_or_else(
|| SharedString::from(url.as_str()),
|name| SharedString::from(name.to_owned()),
)
}
pub(crate) fn opens_run(rows: &[Row], index: usize) -> bool {
let (Some(current), Some(previous)) = (
rows.get(index),
index.checked_sub(1).and_then(|index| rows.get(index)),
) else {
return true;
};
if !matches!((current, previous), (Row::Message(_), Row::Message(_))) {
return true;
}
current.author() != previous.author()
|| current
.created_at()
.as_secs()
.saturating_sub(previous.created_at().as_secs())
> RUN_WINDOW_SECS
}
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 {
match pin {
Pin::Address(coordinate) => SharedString::from(format!("{coordinate}")),
Pin::Event(id) => {
let snippet = group
.message(*id)
.and_then(|message| message.content.lines().next())
.map(str::trim)
.filter(|line| !line.is_empty());
match snippet {
Some(snippet) => SharedString::from(snippet.chars().take(48).collect::<String>()),
None => SharedString::from(id.to_hex().chars().take(8).collect::<String>()),
}
}
}
}
/// 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();
}
+13 -5
View File
@@ -2,7 +2,7 @@ use anyhow::{Error, anyhow};
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Subscription, Task, Window, div, px,
Subscription, Task, Window, div, px, relative,
};
use instant::Duration;
use nostr_connect::prelude::*;
@@ -227,12 +227,12 @@ impl Render for ImportIdentity {
.text_sm()
.child(
v_flex()
.gap_2()
.gap_1()
.child(
v_flex()
.gap_1()
.text_color(cx.theme().text_muted)
.child("Continue with existing key or bunker connection")
.child("Use secret key or bunker connection")
.child(Input::new(&self.key_input)),
)
.when(require_password, |this| {
@@ -247,16 +247,24 @@ impl Render for ImportIdentity {
.when(bunker_warning, |this| {
this.child(
div()
.p_2()
.rounded(cx.theme().radius)
.bg(cx.theme().warning_background)
.text_xs()
.text_color(cx.theme().text_warning)
.text_color(cx.theme().warning_foreground)
.line_height(relative(1.2))
.child(div().child(BUNKER_WARN)),
)
})
.when(key_warning, |this| {
this.child(
div()
.p_2()
.rounded(cx.theme().radius)
.bg(cx.theme().warning_background)
.text_xs()
.text_color(cx.theme().text_warning)
.text_color(cx.theme().warning_foreground)
.line_height(relative(1.2))
.child(div().child(KEY_WARN)),
)
}),
+1 -15
View File
@@ -23,7 +23,7 @@ use ui::{IconName, Root, Sizable, WindowExtension, h_flex, v_flex};
use crate::dialogs::restore::RestoreEncryption;
use crate::dialogs::{join_group, new_chat, new_group, profile, relays, settings};
use crate::panels::{backup, browse, contact_list, greeter, inbox, requests, search};
use crate::panels::{backup, browse, contact_list, greeter, inbox};
use crate::sidebar::Sidebar;
mod dialogs;
@@ -53,9 +53,7 @@ enum Command {
ShowBackup,
ShowContactList,
ShowInbox,
ShowRequests,
ShowBrowse,
ShowSearch,
NewChat,
NewGroup,
JoinGroup,
@@ -294,22 +292,10 @@ impl Workspace {
Command::ShowInbox => {
self.add_panel_to_dock(inbox::init(window, cx), DockPlacement::Center, window, cx);
}
Command::ShowRequests => {
self.add_panel_to_dock(
requests::init(window, cx),
DockPlacement::Center,
window,
cx,
);
}
Command::ShowBrowse => {
let panel = browse::init(self.dock.downgrade(), window, cx);
self.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
}
Command::ShowSearch => {
let panel = search::init(self.dock.downgrade(), window, cx);
self.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
}
Command::NewChat => {
new_chat::open(self.dock.downgrade(), window, cx);
}
-2
View File
@@ -3,5 +3,3 @@ pub mod browse;
pub mod contact_list;
pub mod greeter;
pub mod inbox;
pub mod requests;
pub mod search;
-62
View File
@@ -1,62 +0,0 @@
use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Window,
};
use theme::ActiveTheme;
use ui::dock::{Panel, PanelEvent};
use ui::{Icon, IconName, Sizable, h_flex};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<RequestsPanel> {
cx.new(|cx| RequestsPanel::new(window, cx))
}
pub struct RequestsPanel {
name: SharedString,
focus_handle: FocusHandle,
}
impl RequestsPanel {
fn new(_window: &mut Window, cx: &mut App) -> Self {
Self {
name: "Requests".into(),
focus_handle: cx.focus_handle(),
}
}
}
impl Panel for RequestsPanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, cx: &App) -> AnyElement {
h_flex()
.gap_1p5()
.child(
Icon::new(IconName::Invite)
.small()
.text_color(cx.theme().icon_muted),
)
.child(self.name.clone())
.into_any_element()
}
}
impl EventEmitter<PanelEvent> for RequestsPanel {}
impl Focusable for RequestsPanel {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for RequestsPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.size_full()
.justify_center()
.text_sm()
.text_color(cx.theme().text_muted)
.child(self.name.clone())
}
}
-576
View File
@@ -1,576 +0,0 @@
use std::collections::HashSet;
use std::ops::Range;
use anyhow::Error;
use chat::{ChatRegistry, Room, RoomKind};
use common::DebouncedDelay;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity,
Window, div, uniform_list,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use state::{FIND_DELAY, NostrRegistry};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
use ui::input::{Input, InputEvent, InputState};
use ui::nav::Nav;
use ui::notification::Notification;
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
const INPUT_PLACEHOLDER: &str = "Find or start a conversation";
pub fn init(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) -> Entity<SearchPanel> {
cx.new(|cx| SearchPanel::new(dock, window, cx))
}
pub struct SearchPanel {
name: SharedString,
focus_handle: FocusHandle,
/// The dock a started chat opens in
dock: WeakEntity<DockArea>,
/// Find input state
find_input: Entity<InputState>,
/// Debounced delay for find input
find_debouncer: DebouncedDelay<Self>,
/// Whether a search is in progress
finding: bool,
/// Find results
find_results: Entity<Option<Vec<PublicKey>>>,
/// Async find operation
find_task: Option<Task<Result<(), Error>>>,
/// Selected public keys
selected_pkeys: Entity<HashSet<PublicKey>>,
/// User's contacts
contact_list: Entity<Option<Vec<PublicKey>>>,
/// Async tasks
tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl SearchPanel {
fn new(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let contact_list = cx.new(|_| None);
let selected_pkeys = cx.new(|_| HashSet::new());
let find_results = cx.new(|_| None);
let find_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(INPUT_PLACEHOLDER)
.clean_on_escape()
});
let mut subscriptions = smallvec![];
subscriptions.push(
// Subscribe to find input events
cx.subscribe_in(&find_input, window, |this, state, event, window, cx| {
let delay = Duration::from_millis(FIND_DELAY);
match event {
InputEvent::PressEnter { .. } => {
this.search(window, cx);
}
InputEvent::Change => {
if state.read(cx).value().is_empty() {
// Clear results when input is empty
this.reset(window, cx);
} else {
// Run debounced search
this.find_debouncer
.fire_new(delay, window, cx, |this, window, cx| {
this.debounced_search(window, cx)
});
}
}
InputEvent::Focus => {
this.get_contact_list(window, cx);
}
_ => {}
};
}),
);
Self {
name: "Search".into(),
focus_handle: cx.focus_handle(),
dock,
find_input,
find_debouncer: DebouncedDelay::new(),
find_results,
find_task: None,
finding: false,
contact_list,
selected_pkeys,
tasks: smallvec![],
_subscriptions: subscriptions,
}
}
/// Get the contact list.
fn get_contact_list(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let Some(public_key) = nostr.read(cx).current_user() else {
return;
};
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move {
let filter = Filter::new()
.author(public_key)
.kind(Kind::ContactList)
.limit(1);
let contacts: HashSet<PublicKey> = client
.database()
.query(filter)
.await?
.into_iter()
.next()
.map(|event| event.tags.public_keys().collect())
.unwrap_or_default();
Ok(contacts)
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(contacts) => {
this.update(cx, |this, cx| {
this.set_contact_list(contacts, cx);
})?;
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(
Notification::error(e.to_string()).autohide(false),
cx,
);
})?;
}
};
Ok(())
}));
}
/// Set the contact list with new contacts.
fn set_contact_list<I>(&mut self, contacts: I, cx: &mut Context<Self>)
where
I: IntoIterator<Item = PublicKey>,
{
self.contact_list.update(cx, |this, cx| {
*this = Some(contacts.into_iter().collect());
cx.notify();
});
}
/// Trigger the debounced search
fn debounced_search(&self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
cx.spawn_in(window, async move |this, cx| {
this.update_in(cx, |this, window, cx| {
this.search(window, cx);
})
.ok();
})
}
/// Search
fn search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Get query
let query = self.find_input.read(cx).value();
// Return if the query is empty
if query.is_empty() {
return;
}
// Block the input until the search completes
self.set_finding(true, window, cx);
// Create the search task
let nostr = NostrRegistry::global(cx);
let find_users = nostr.read(cx).search(&query, cx);
// Run task in the main thread
self.find_task = Some(cx.spawn_in(window, async move |this, cx| {
let rooms = find_users.await?;
// Update the UI with the search results
this.update_in(cx, |this, window, cx| {
this.set_results(rooms, cx);
this.set_finding(false, window, cx);
})?;
Ok(())
}));
}
/// Set the results of the search
fn set_results(&mut self, results: Vec<PublicKey>, cx: &mut Context<Self>) {
self.find_results.update(cx, |this, cx| {
*this = Some(results);
cx.notify();
});
}
/// Set the finding status
fn set_finding(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
// Disable the input to prevent duplicate requests
self.find_input.update(cx, |this, cx| {
this.set_loading(status, window, cx);
});
// Set the search status
self.finding = status;
cx.notify();
}
fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Clear all search results
self.find_results.update(cx, |this, cx| {
*this = None;
cx.notify();
});
// Clear all selected public keys
self.selected_pkeys.update(cx, |this, cx| {
this.clear();
cx.notify();
});
// Reset the search status
self.set_finding(false, window, cx);
// Cancel the current search task
self.find_task = None;
cx.notify();
}
/// Select a public key in the search panel.
fn select(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
self.selected_pkeys.update(cx, |this, cx| {
if this.contains(public_key) {
this.remove(public_key);
} else {
this.insert(public_key.to_owned());
}
cx.notify();
});
}
/// Check if a public key is selected in the search panel.
fn is_selected(&self, public_key: &PublicKey, cx: &App) -> bool {
self.selected_pkeys.read(cx).contains(public_key)
}
/// Get all selected public keys in the search panel.
fn get_selected(&self, cx: &Context<Self>) -> HashSet<PublicKey> {
self.selected_pkeys.read(cx).clone()
}
/// Create a new room
fn create_room(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let chat = ChatRegistry::global(cx);
let async_chat = chat.downgrade();
let dock = self.dock.clone();
let nostr = NostrRegistry::global(cx);
let Some(public_key) = nostr.read(cx).current_user() else {
return;
};
// Get all selected public keys
let receivers = self.get_selected(cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
// Create a new room and register it
let room = async_chat.update_in(cx, |chat, _window, cx| {
let room = cx.new(|_| {
Room::new(public_key, receivers)
.organize(&public_key)
.kind(RoomKind::Ongoing)
});
chat.track_room(&room, cx);
room
})?;
// Open it in the dock
cx.update(|window, cx| {
ui::dock::add_panel_to(
&dock,
PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)),
DockPlacement::Center,
window,
cx,
);
})?;
// Reset the find panel
this.update_in(cx, |this, window, cx| {
this.reset(window, cx);
})?;
Ok(())
}));
}
/// Render the search results
fn render_results(
&self,
range: Range<usize>,
cx: &Context<Self>,
) -> Vec<impl IntoElement + use<>> {
let hide_avatar = AppSettings::get_hide_avatar(cx);
let persons = PersonRegistry::global(cx);
// Get the results
let Some(results) = self.find_results.read(cx) else {
return vec![];
};
// Map the results to a list of elements
results
.get(range.clone())
.into_iter()
.flatten()
.enumerate()
.map(|(ix, public_key)| {
let selected = self.is_selected(public_key, cx);
let profile = persons.read(cx).get(public_key, cx);
let pkey_clone = public_key.to_owned();
let handler = cx.listener(move |this, _ev, _window, cx| {
this.select(&pkey_clone, cx);
});
Nav::new(range.start + ix)
.label(profile.name())
.text_sm()
.font_medium()
.when(!hide_avatar, |this| {
this.prefix(
Avatar::from_source(profile.avatar())
.seed(profile.avatar_seed())
.small()
.flex_shrink_0(),
)
})
.on_click(handler)
.selected(selected)
.into_any_element()
})
.collect()
}
/// Render the contact list
fn render_contacts(
&self,
range: Range<usize>,
cx: &Context<Self>,
) -> Vec<impl IntoElement + use<>> {
let hide_avatar = AppSettings::get_hide_avatar(cx);
let persons = PersonRegistry::global(cx);
// Get the contact list
let Some(contacts) = self.contact_list.read(cx) else {
return vec![];
};
// Map the contact list to a list of elements
contacts
.get(range.clone())
.into_iter()
.flatten()
.enumerate()
.map(|(ix, public_key)| {
let selected = self.is_selected(public_key, cx);
let profile = persons.read(cx).get(public_key, cx);
let pkey_clone = public_key.to_owned();
let handler = cx.listener(move |this, _ev, _window, cx| {
this.select(&pkey_clone, cx);
});
Nav::new(range.start + ix)
.label(profile.name().trim())
.text_sm()
.font_medium()
.when(!hide_avatar, |this| {
this.prefix(
Avatar::from_source(profile.avatar())
.seed(profile.avatar_seed())
.small()
.flex_shrink_0(),
)
})
.on_click(handler)
.selected(selected)
.into_any_element()
})
.collect()
}
}
impl Panel for SearchPanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
}
fn title(&self, cx: &App) -> AnyElement {
h_flex()
.gap_1p5()
.child(
Icon::new(IconName::Search)
.small()
.text_color(cx.theme().icon_muted),
)
.child(self.name.clone())
.into_any_element()
}
}
impl EventEmitter<PanelEvent> for SearchPanel {}
impl Focusable for SearchPanel {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for SearchPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let nostr = NostrRegistry::global(cx);
let chat = ChatRegistry::global(cx);
let logged_in = nostr.read(cx).current_user().is_some();
let loading = chat.read(cx).loading() && logged_in;
// Set button label based on total selected users
let button_label = if self.selected_pkeys.read(cx).len() > 1 {
"Create Group DM"
} else {
"Create DM"
};
v_flex()
.size_full()
.gap_3()
.p_2()
.child(
h_flex().child(
Input::new(&self.find_input)
.small()
.text_xs()
.disabled(loading)
.when(
!self.find_input.read(cx).presentation().is_loading(),
|this| {
this.suffix(
Button::new("find-icon")
.icon(IconName::Search)
.tooltip("Press Enter to search")
.transparent()
.small(),
)
},
),
),
)
.child(
v_flex()
.flex_1()
.gap_3()
.when_some(self.find_results.read(cx).as_ref(), |this, results| {
this.child(
v_flex()
.gap_1()
.flex_1()
.border_b_1()
.border_color(cx.theme().border_variant)
.child(
h_flex()
.gap_0p5()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(Icon::new(IconName::ChevronDown))
.child("Results"),
)
.child(
uniform_list(
"rooms",
results.len(),
cx.processor(move |this, range, _window, cx| {
this.render_results(range, cx)
}),
)
.flex_1()
.h_full(),
),
)
})
.when_some(self.contact_list.read(cx).as_ref(), |this, contacts| {
this.child(
v_flex()
.gap_1()
.flex_1()
.child(
h_flex()
.gap_0p5()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(Icon::new(IconName::ChevronDown).small())
.child("Contacts"),
)
.child(
uniform_list(
"contacts",
contacts.len(),
cx.processor(|this, range, _window, cx| {
this.render_contacts(range, cx)
}),
)
.flex_1()
.h_full(),
),
)
}),
)
.when(!self.selected_pkeys.read(cx).is_empty(), |this| {
this.child(
div()
.absolute()
.bottom_2()
.left_0()
.h_9()
.w_full()
.px_4()
.child(
Button::new("create")
.label(button_label)
.primary()
.rounded()
.shadow_md()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.create_room(window, cx);
})),
),
)
})
}
}
@@ -256,6 +256,7 @@ impl Sidebar {
let persons = PersonRegistry::global(cx);
let person = persons.read(cx).get(public_key, cx);
let public_key = *public_key;
let member_is_admin = !roles.is_empty();
let group = group.clone();
@@ -297,6 +298,22 @@ impl Sidebar {
return menu;
}
let menu = menu.separator().item(if member_is_admin {
PopupMenuItem::new("Remove admin").on_click({
let group = group.clone();
move |_event, window, cx| {
nip29_ui::remove_admin(group.clone(), public_key, window, cx);
}
})
} else {
PopupMenuItem::new("Make admin").on_click({
let group = group.clone();
move |_event, window, cx| {
nip29_ui::make_admin(group.clone(), public_key, window, cx);
}
})
});
menu.separator()
.item(PopupMenuItem::new("Remove").on_click({
let group = group.clone();