add group attachments and sidebar toggle

This commit is contained in:
2026-10-01 08:25:52 +07:00
parent 61aa7c4a70
commit 857795a92e
9 changed files with 467 additions and 54 deletions
+1
View File
@@ -2,6 +2,7 @@ pub mod import;
pub mod join_group;
pub mod new_chat;
pub mod new_group;
pub mod profile;
pub mod restore;
pub mod screening;
pub mod settings;
+166
View File
@@ -0,0 +1,166 @@
use anyhow::Error;
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, ClipboardItem, Context, Div, IntoElement, ParentElement, Render, SharedString,
Styled, Task, Window, div, px,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry, shorten_pubkey};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::{IconName, Sizable, WindowExtension, h_flex, v_flex};
/// Open a read-only profile for `public_key`.
pub fn open(public_key: PublicKey, window: &mut Window, cx: &mut App) {
let profile = cx.new(|_| ProfileDialog::new(public_key));
window.open_dialog(cx, move |this, _window, _cx| {
this.width(px(360.)).title("Profile").child(profile.clone())
});
}
struct ProfileDialog {
public_key: PublicKey,
copied: bool,
tasks: Vec<Task<Result<(), Error>>>,
}
impl ProfileDialog {
fn new(public_key: PublicKey) -> Self {
Self {
public_key,
copied: false,
tasks: vec![],
}
}
fn profile(&self, cx: &App) -> Person {
let persons = PersonRegistry::global(cx);
persons.read(cx).get(&self.public_key, cx)
}
fn copy(&mut self, value: String, window: &mut Window, cx: &mut Context<Self>) {
let item = ClipboardItem::new_string(value);
cx.write_to_clipboard(item);
self.set_copied(true, window, cx);
}
fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
self.copied = status;
cx.notify();
if status {
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
// Reset the copied state after a delay
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_copied(false, window, cx);
})
.ok();
})
.ok();
Ok(())
}));
}
}
}
impl Render for ProfileDialog {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let profile = self.profile(cx);
let metadata = profile.metadata();
// Get the public key as short string
let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
let about = metadata
.about
.filter(|about| !about.trim().is_empty())
.map(SharedString::from);
let website = metadata
.website
.filter(|website| !website.trim().is_empty())
.map(SharedString::from);
v_flex()
.gap_3()
.w_full()
.child(
v_flex()
.h_40()
.w_full()
.items_center()
.justify_center()
.child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.large(),
),
)
.child(
v_flex()
.gap_1p5()
.child(label("Name", cx))
.child(div().text_sm().child(profile.name())),
)
.when_some(about, |this, about| {
this.child(
v_flex()
.gap_1p5()
.child(label("About", cx))
.child(div().text_sm().child(about)),
)
})
.when_some(website, |this, website| {
this.child(
v_flex()
.gap_1p5()
.child(label("Website", cx))
.child(div().text_sm().child(website)),
)
})
.child(
v_flex().gap_1p5().child(label("Public Key", cx)).child(
h_flex()
.h_8()
.w_full()
.justify_center()
.gap_3()
.rounded(cx.theme().radius)
.bg(cx.theme().secondary_background)
.text_sm()
.text_color(cx.theme().secondary_foreground)
.child(shorten_pkey)
.child(
Button::new("copy")
.icon({
if self.copied {
IconName::CheckCircle
} else {
IconName::Copy
}
})
.xsmall()
.secondary()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.copy(this.public_key.to_bech32().unwrap(), window, cx);
})),
),
),
)
}
}
fn label(label: &'static str, cx: &App) -> Div {
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child(label)
}
+29 -1
View File
@@ -10,6 +10,7 @@ use gpui::{
Action, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement,
Render, SharedString, Styled, Subscription, Task, Window, div, px,
};
use nip29::{GroupId, GroupKey};
use nostr_sdk::prelude::*;
use serde::Deserialize;
use smallvec::{SmallVec, smallvec};
@@ -66,6 +67,7 @@ enum Command {
pub struct Workspace {
dock: Entity<DockArea>,
sidebar: Entity<Sidebar>,
/// Async tasks
tasks: Vec<Task<Result<(), Error>>>,
/// Event subscriptions
@@ -80,6 +82,7 @@ impl Workspace {
let (dock, _) = dock::dock_area("coop", window, cx);
let sidebar = cx.new(|cx| Sidebar::new(window, dock.downgrade(), cx));
let left_sidebar = sidebar.clone();
let mut subscriptions = smallvec![];
@@ -187,7 +190,7 @@ impl Workspace {
);
cx.defer_in(window, move |this, window, cx| {
let sidebar = PanelHandle::new(sidebar);
let sidebar = PanelHandle::new(left_sidebar);
let greeter = PanelHandle::new(greeter::init(window, cx));
this.dock.update(cx, |this, cx| {
@@ -201,8 +204,20 @@ impl Workspace {
});
});
// Listen app-wide, so a group panel can toggle the sidebar without holding it.
let workspace = cx.entity().downgrade();
App::on_action(cx, move |action: &nip29_ui::ToggleGroupMeta, cx| {
let Some(workspace) = workspace.upgrade() else {
return;
};
workspace.update(cx, |this, cx| this.toggle_group_meta(action, cx));
});
Self {
dock,
sidebar,
tasks: vec![],
_subscriptions: subscriptions,
}
@@ -382,6 +397,19 @@ impl Workspace {
}
}
/// Show or hide the sidebar's information for the group a panel belongs to.
fn toggle_group_meta(&mut self, action: &nip29_ui::ToggleGroupMeta, cx: &mut Context<Self>) {
let Ok(id) = GroupId::new(action.group.clone()) else {
return;
};
let key = GroupKey::new(action.relay.clone(), id);
self.sidebar.update(cx, |sidebar, cx| {
sidebar.toggle_group(&key, cx);
});
}
fn confirm_reset_encryption(&mut self, window: &mut Window, cx: &mut Context<Self>) {
const ENC_MSG: &str = "Encryption Key is a special key that used to encrypt and decrypt your messages. \
Your identity is completely decoupled from all encryption processes to protect your privacy.";
+58 -20
View File
@@ -13,7 +13,7 @@ use gpui::{
Styled, StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, div, img,
px, retain_all, uniform_list,
};
use nip29::{Group, GroupsEvent, GroupsRegistry, Membership};
use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry, Membership};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use settings::AppSettings;
@@ -38,7 +38,7 @@ use ui::{
};
use crate::Command;
use crate::dialogs::{import, screening};
use crate::dialogs::{import, profile, screening};
mod tab;
mod utils;
@@ -87,6 +87,8 @@ pub struct Sidebar {
/// The group whose information the sidebar is browsing, if any.
group: Option<WeakEntity<Group>>,
/// Whether the sidebar is showing the group's information instead of its tabs.
group_open: bool,
/// Scroll state of the group's member list.
group_scroll: UniformListScrollHandle,
/// Whether the group view's admins section is expanded.
@@ -165,6 +167,7 @@ impl Sidebar {
banner: pick_banner(),
active_tab: SidebarTab::Inbox,
group: None,
group_open: false,
group_scroll: UniformListScrollHandle::new(),
admins_open: true,
members_open: true,
@@ -183,14 +186,41 @@ impl Sidebar {
/// Show the group view, replacing the tab list.
fn show_group(&mut self, group: &Entity<Group>, cx: &mut Context<Self>) {
self.group = Some(group.downgrade());
self.group_open = true;
cx.notify();
}
/// Show or hide the group view depending on which group is being viewed.
pub(crate) fn toggle_group(&mut self, key: &GroupKey, cx: &mut Context<Self>) {
let groups = GroupsRegistry::global(cx);
let Some(entity) = groups.read(cx).group(key, cx) else {
return;
};
let open = self
.group
.as_ref()
.and_then(|group| group.upgrade())
.is_some_and(|current| current.entity_id() == entity.entity_id());
if open {
self.group_open = !self.group_open;
} else {
self.group = Some(entity.downgrade());
self.group_open = true;
}
cx.notify();
}
/// Leave the group view, returning the sidebar to its tab list.
fn reset_group(&mut self, cx: &mut Context<Self>) {
if self.group.take().is_none() {
fn hide_group(&mut self, cx: &mut Context<Self>) {
if !self.group_open {
return;
}
self.group_open = false;
cx.notify();
}
@@ -430,7 +460,7 @@ impl Sidebar {
.when_some(AutoUpdater::try_global(cx), |this, updater| {
this.child(self.render_updater(updater, cx))
})
.when(self.group.is_some(), |this| {
.when(self.group_open, |this| {
this.child(
Button::new("sidebar-back")
.icon(IconName::ArrowLeft)
@@ -438,7 +468,7 @@ impl Sidebar {
.ghost()
.small()
.on_click(cx.listener(|this, _event, _window, cx| {
this.reset_group(cx);
this.hide_group(cx);
})),
)
})
@@ -935,7 +965,9 @@ impl Sidebar {
let metadata = group.metadata();
(
metadata.and_then(|metadata| metadata.banner.clone()),
metadata.and_then(|metadata| {
metadata.banner.clone().or_else(|| metadata.picture.clone())
}),
group.display_name(),
group.key().cache_tag(),
group.display_image(),
@@ -950,6 +982,18 @@ impl Sidebar {
.min_h_0()
.w_full()
.gap_2()
.when_some(banner, |this, banner| {
this.child(
div().px_2().flex_shrink_0().child(
div()
.h_40()
.w_full()
.overflow_hidden()
.rounded(cx.theme().radius_lg)
.child(img(banner).size_full().object_fit(ObjectFit::Cover)),
),
)
})
.child(
h_flex()
.px_2()
@@ -966,17 +1010,6 @@ impl Sidebar {
.child(name),
),
)
.when_some(banner, |this, banner| {
this.child(
div().px_2().flex_shrink_0().child(
img(banner)
.w_full()
.h_20()
.rounded(cx.theme().radius_lg)
.object_fit(ObjectFit::Cover),
),
)
})
.child(
div()
.min_h_0()
@@ -1073,6 +1106,11 @@ impl Sidebar {
.child(SharedString::from(roles.join(", "))),
)
})
.on_click({
let public_key = *public_key;
move |_event, window, cx| profile::open(public_key, window, cx)
})
.into_any_element()
}
}
@@ -1175,8 +1213,8 @@ impl Render for Sidebar {
)
})
.map(|this| match group {
Some(group) => this.child(self.render_group(group, cx)),
None => this.child(self.render_tabs(cx)),
Some(group) if self.group_open => this.child(self.render_group(group, cx)),
_ => this.child(self.render_tabs(cx)),
})
.when(loading && logged_in, |this| {
this.child(