This commit is contained in:
2026-09-19 20:58:56 +07:00
parent 323545ce16
commit 52f92c226f
6 changed files with 310 additions and 288 deletions
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M12 7.75V12L15.5 15.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M2.75 4.75V8.75H6.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.25 15.0833C4.52169 18.676 7.95303 21.25 11.9864 21.25C17.1026 21.25 21.25 17.1086 21.25 12C21.25 6.89137 17.1026 2.75 11.9864 2.75C8.14808 2.75 4.85497 5.08106 3.44947 8.40278" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 600 B

-6
View File
@@ -46,7 +46,6 @@ setting_accessors! {
pub nip4e: bool,
pub trusted_relays: Vec<String>,
pub file_server: Url,
pub expanded_sections: Option<Vec<String>>,
}
/// Signer kind
@@ -131,10 +130,6 @@ pub struct Settings {
/// Server for blossom media attachments
pub file_server: Url,
/// Expanded sidebar tree sections; `None` means the default sections
#[serde(default)]
pub expanded_sections: Option<Vec<String>>,
}
impl Default for Settings {
@@ -147,7 +142,6 @@ impl Default for Settings {
nip4e: false,
trusted_relays: vec![],
file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(),
expanded_sections: None,
}
}
}
+2
View File
@@ -66,6 +66,7 @@ pub enum IconName {
Ship,
Shield,
Group,
History,
UserKey,
Upload,
Usb,
@@ -145,6 +146,7 @@ impl IconNamed for IconName {
Self::Upload => "icons/upload.svg",
Self::Usb => "icons/usb.svg",
Self::Group => "icons/group.svg",
Self::History => "icons/history.svg",
Self::PanelLeft => "icons/panel-left.svg",
Self::PanelLeftOpen => "icons/panel-left-open.svg",
Self::PanelRight => "icons/panel-right.svg",
+199 -241
View File
@@ -1,4 +1,3 @@
use std::collections::BTreeSet;
use std::ops::Range;
use std::rc::Rc;
@@ -13,44 +12,41 @@ use gpui::{
UniformListScrollHandle, Window, div, px, retain_all, uniform_list,
};
use person::PersonRegistry;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use state::NostrRegistry;
use theme::{ActiveTheme, TABBAR_HEIGHT};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock::{ClosePanel, Panel, PanelEvent};
use ui::dock::{Panel, PanelEvent};
use ui::indicator::Indicator;
use ui::menu::{DropdownMenu, PopupMenuItem};
use ui::modal::ModalButtonProps;
use ui::nav_item::NavItem;
use ui::scroll::Scrollbar;
use ui::{
Icon, IconName, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, WindowExtension, h_flex,
title_bar_drag_handlers, v_flex,
Icon, IconName, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, h_flex, title_bar_drag_handlers,
v_flex,
};
use crate::Command;
use crate::dialogs::screening;
mod tab;
mod tree;
use tree::{SidebarRow, TreeSection};
use tab::{SidebarTab, TabBar};
use tree::SidebarRow;
pub(crate) use tree::{TreeRow, TreeRowKind};
pub struct Sidebar {
focus_handle: FocusHandle,
scroll_handle: UniformListScrollHandle,
scroll_handles: [UniformListScrollHandle; 3],
active_tab: SidebarTab,
/// Whether there are new chat requests
new_requests: bool,
/// Expanded tree sections
expanded: BTreeSet<TreeSection>,
_subscriptions: SmallVec<[Subscription; 2]>,
}
impl Sidebar {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let settings = AppSettings::global(cx).read(cx).entity().clone();
let chat = ChatRegistry::global(cx);
let communities = CommunityRegistry::global(cx);
@@ -65,10 +61,6 @@ impl Sidebar {
}),
);
subscriptions.push(cx.observe(&settings, move |this, _settings, cx| {
this.restore_state(cx);
}));
subscriptions.push(
cx.subscribe(&communities, |_this, _communities, event, _cx| {
if let CommunityEvent::Error(error) = event {
@@ -79,192 +71,26 @@ impl Sidebar {
Self {
focus_handle: cx.focus_handle(),
scroll_handle: UniformListScrollHandle::new(),
scroll_handles: [
UniformListScrollHandle::new(),
UniformListScrollHandle::new(),
UniformListScrollHandle::new(),
],
active_tab: SidebarTab::Recents,
new_requests: false,
expanded: load_expanded(cx),
_subscriptions: subscriptions,
}
}
fn toggle_section(&mut self, section: TreeSection, cx: &mut Context<Self>) {
if !self.expanded.remove(&section) {
self.expanded.insert(section);
}
self.save_expanded(cx);
cx.notify();
}
fn is_expanded(&self, section: TreeSection) -> bool {
self.expanded.contains(&section)
}
fn restore_state(&mut self, cx: &mut Context<Self>) {
let expanded = load_expanded(cx);
if self.expanded == expanded {
fn select_tab(&mut self, tab: SidebarTab, cx: &mut Context<Self>) {
if self.active_tab == tab {
return;
}
self.expanded = expanded;
self.active_tab = tab;
cx.notify();
}
fn save_expanded(&self, cx: &mut App) {
let keys = self
.expanded
.iter()
.map(|section| section.key().to_string())
.collect();
AppSettings::update_expanded_sections(Some(keys), cx);
}
fn tree_rows(&self, cx: &App) -> Vec<SidebarRow> {
let chat = ChatRegistry::global(cx);
let chat = chat.read(cx);
let mut rows = Vec::new();
let registry = CommunityRegistry::global(cx);
let communities = registry.read(cx).communities();
rows.push(SidebarRow::Section {
section: TreeSection::Community,
count: communities.len(),
});
if self.is_expanded(TreeSection::Community) {
if communities.is_empty() {
rows.push(SidebarRow::Hint {
text: "No communities yet".into(),
});
} else {
rows.extend(
communities
.iter()
.cloned()
.map(|community| SidebarRow::Community { community }),
);
}
}
let messages = chat.rooms(&RoomKind::Ongoing, cx);
rows.push(SidebarRow::Section {
section: TreeSection::Messages,
count: messages.len(),
});
if self.is_expanded(TreeSection::Messages) {
if messages.is_empty() {
rows.push(SidebarRow::Hint {
text: "No conversations yet".into(),
});
} else {
rows.extend(messages.into_iter().map(|room| SidebarRow::Room { room }));
}
}
rows
}
fn render_rows(
&self,
range: Range<usize>,
rows: &[SidebarRow],
cx: &Context<Self>,
) -> Vec<AnyElement> {
rows.get(range.clone())
.into_iter()
.flatten()
.enumerate()
.map(|(offset, row)| {
let index = range.start + offset;
match row {
SidebarRow::Section { section, count } => {
let section = *section;
TreeRow::new(
ElementId::NamedInteger("tree-row".into(), index as u64),
TreeRowKind::Section,
section.label(),
)
.caret(if self.is_expanded(section) {
IconName::CaretDown
} else {
IconName::CaretRight
})
.count(*count)
.on_click(cx.listener(move |this, _event, _window, cx| {
this.toggle_section(section, cx);
}))
.into_any_element()
}
SidebarRow::Room { room } => {
let public_key = room.read(cx).display_member(cx).public_key();
let name = room.read(cx).display_name(cx);
let picture = room.read(cx).display_image(cx);
let seed = room.read(cx).display_image_seed(cx);
let kind = room.read(cx).kind;
let created_at = room.read(cx).created_at.to_ago();
let room_clone = room.clone();
let handler = cx.listener(move |_this, _event, window, cx| {
ChatRegistry::global(cx).update(cx, |chat, cx| {
chat.emit_room(&room_clone, window, cx);
});
if kind != RoomKind::Ongoing && AppSettings::get_screening(cx) {
let screening = screening::init(public_key, window, cx);
window.open_modal(cx, move |this, _window, _cx| {
this.confirm()
.child(screening.clone())
.button_props(
ModalButtonProps::default()
.cancel_text("Ignore")
.ok_text("Response"),
)
.on_cancel(move |_event, window, cx| {
window.dispatch_action(Box::new(ClosePanel), cx);
true
})
});
}
});
TreeRow::new(
ElementId::NamedInteger("tree-row".into(), index as u64),
TreeRowKind::Room,
name,
)
.avatar(seed)
.picture(picture)
.created_at(created_at)
.on_click(handler)
.into_any_element()
}
SidebarRow::Community { community } => {
let community = community.read(cx);
TreeRow::new(
ElementId::NamedInteger("tree-row".into(), index as u64),
TreeRowKind::Community,
community.name(),
)
.avatar(community.id().to_hex())
.picture(community.icon())
.into_any_element()
}
SidebarRow::Hint { text } => TreeRow::new(
ElementId::NamedInteger("tree-row".into(), index as u64),
TreeRowKind::Hint,
text.clone(),
)
.into_any_element(),
}
})
.collect()
}
fn render_user(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let nostr = NostrRegistry::global(cx);
let current_user = nostr.read(cx).current_user();
@@ -318,6 +144,16 @@ impl Sidebar {
.child(name.clone())
}))
.separator()
.menu_with_icon(
"Inbox",
IconName::Inbox,
Box::new(Command::ShowInbox),
)
.menu_with_icon(
"Search",
IconName::Search,
Box::new(Command::ShowSearch),
)
.menu_with_icon(
"Profile",
IconName::Profile,
@@ -359,13 +195,118 @@ impl Sidebar {
}
}
fn load_expanded(cx: &App) -> BTreeSet<TreeSection> {
let Some(keys) = AppSettings::get_expanded_sections(cx) else {
return BTreeSet::from([TreeSection::Community, TreeSection::Messages]);
};
fn rows_for(tab: SidebarTab, cx: &App) -> Vec<SidebarRow> {
match tab {
SidebarTab::Recents => vec![SidebarRow::Hint {
text: "Nothing recent yet".into(),
}],
SidebarTab::Chats => {
let chat = ChatRegistry::global(cx);
let chat = chat.read(cx);
let messages = chat.rooms(&RoomKind::Ongoing, cx);
keys.iter()
.filter_map(|key| TreeSection::from_key(key.as_str()))
let mut rows = vec![SidebarRow::Section {
label: "Chats".into(),
count: messages.len(),
}];
if messages.is_empty() {
rows.push(SidebarRow::Hint {
text: "No conversations yet".into(),
});
} else {
rows.extend(messages.into_iter().map(|room| SidebarRow::Room { room }));
}
rows
}
SidebarTab::Communities => {
let registry = CommunityRegistry::global(cx);
let communities = registry.read(cx).communities();
let mut rows = vec![SidebarRow::Section {
label: "Communities".into(),
count: communities.len(),
}];
if communities.is_empty() {
rows.push(SidebarRow::Hint {
text: "No communities yet".into(),
});
} else {
rows.extend(
communities
.iter()
.cloned()
.map(|community| SidebarRow::Community { community }),
);
}
rows
}
}
}
fn render_rows(range: Range<usize>, rows: &[SidebarRow], cx: &Context<Sidebar>) -> Vec<AnyElement> {
rows.get(range.clone())
.into_iter()
.flatten()
.enumerate()
.map(|(offset, row)| {
let index = range.start + offset;
match row {
SidebarRow::Section { label, count } => TreeRow::new(
ElementId::NamedInteger("tree-row".into(), index as u64),
TreeRowKind::Section,
label.clone(),
)
.count(*count)
.into_any_element(),
SidebarRow::Room { room } => {
let name = room.read(cx).display_name(cx);
let picture = room.read(cx).display_image(cx);
let seed = room.read(cx).display_image_seed(cx);
let created_at = room.read(cx).created_at.to_ago();
let room_clone = room.clone();
let handler = cx.listener(move |_this, _event, window, cx| {
ChatRegistry::global(cx).update(cx, |chat, cx| {
chat.emit_room(&room_clone, window, cx);
});
});
TreeRow::new(
ElementId::NamedInteger("tree-row".into(), index as u64),
TreeRowKind::Room,
name,
)
.avatar(seed)
.picture(picture)
.created_at(created_at)
.on_click(handler)
.into_any_element()
}
SidebarRow::Community { community } => {
let community = community.read(cx);
TreeRow::new(
ElementId::NamedInteger("tree-row".into(), index as u64),
TreeRowKind::Community,
community.name(),
)
.avatar(community.id().to_hex())
.picture(community.icon())
.into_any_element()
}
SidebarRow::Hint { text } => TreeRow::new(
ElementId::NamedInteger("tree-row".into(), index as u64),
TreeRowKind::Hint,
text.clone(),
)
.into_any_element(),
}
})
.collect()
}
@@ -404,86 +345,103 @@ impl Render for Sidebar {
let loading = chat.read(cx).loading() && logged_in;
let sidebar = cx.entity().downgrade();
let rows = Rc::new(self.tree_rows(cx));
let active_tab = self.active_tab;
let rows = Rc::new(rows_for(active_tab, cx));
let scroll_handle = &self.scroll_handles[active_tab.index()];
v_flex()
.image_cache(retain_all("sidebar"))
.size_full()
.relative()
.gap_2()
.bg(cx.theme().surface_background)
.border_r_1()
.border_color(cx.theme().border_variant)
.child(self.render_user(window, cx))
.child(
v_flex()
.px_2()
.gap_1()
.child(
NavItem::new("nav-inbox", "Inbox", Icon::new(IconName::Inbox).small())
.when(active_tab == SidebarTab::Chats, |this| {
this.child(
v_flex()
.px_2()
.gap_1()
.child(
NavItem::new(
"nav-contacts",
"Contacts",
Icon::new(IconName::Book).small(),
)
.on_click(|_event, window, cx| {
window.dispatch_action(Box::new(Command::ShowInbox), cx)
window.dispatch_action(Box::new(Command::ShowContactList), cx)
}),
)
.child(
NavItem::new(
"nav-requests",
"Requests",
Icon::new(IconName::Invite).small(),
)
.when(self.new_requests, |this| {
this.suffix(div().size_1().rounded_full().bg(cx.theme().cursor))
})
.on_click({
let sidebar = sidebar.clone();
move |_event, window, cx| {
if let Err(error) = sidebar.update(cx, |this, cx| {
this.new_requests = false;
cx.notify();
}) {
log::error!("Failed to clear new requests: {error}");
.child(
NavItem::new(
"nav-requests",
"Requests",
Icon::new(IconName::Invite).small(),
)
.when(self.new_requests, |this| {
this.suffix(div().size_1().rounded_full().bg(cx.theme().cursor))
})
.on_click({
let sidebar = sidebar.clone();
move |_event, window, cx| {
if let Err(error) = sidebar.update(cx, |this, cx| {
this.new_requests = false;
cx.notify();
}) {
log::error!("Failed to clear new requests: {error}");
}
window.dispatch_action(Box::new(Command::ShowRequests), cx);
}
window.dispatch_action(Box::new(Command::ShowRequests), cx);
}
}),
)
.child(
}),
),
)
})
.when(active_tab == SidebarTab::Communities, |this| {
this.child(
v_flex().px_2().gap_1().child(
NavItem::new("nav-browse", "Browse", Icon::new(IconName::Compass).small())
.on_click(|_event, window, cx| {
window.dispatch_action(Box::new(Command::ShowBrowse), cx)
}),
)
.child(
NavItem::new("nav-search", "Search", Icon::new(IconName::Search).small())
.on_click(|_event, window, cx| {
window.dispatch_action(Box::new(Command::ShowSearch), cx)
}),
),
)
)
})
.child(
v_flex()
.size_full()
.flex_1()
.min_h_0()
.gap_1()
.pb_12()
.child(
uniform_list(
"sidebar-tree",
active_tab.list_id(),
rows.len(),
cx.processor(move |this, range, _window, cx| {
this.render_rows(range, rows.as_slice(), cx)
cx.processor(move |_this, range, _window, cx| {
render_rows(range, rows.as_slice(), cx)
}),
)
.track_scroll(&self.scroll_handle)
.track_scroll(scroll_handle)
.flex_1()
.h_full()
.px_2(),
)
.child(Scrollbar::vertical(&self.scroll_handle)),
.child(Scrollbar::vertical(scroll_handle)),
)
.child(TabBar::new(active_tab).on_select({
let sidebar = sidebar.clone();
move |tab, _window, cx| {
if let Err(error) = sidebar.update(cx, |this, cx| this.select_tab(tab, cx)) {
log::error!("Failed to switch sidebar tab: {error}");
}
}
}))
.when(loading, |this| {
this.child(
div()
.absolute()
.bottom_2()
.bottom_12()
.left_0()
.h_9()
.w_full()
+105
View File
@@ -0,0 +1,105 @@
use std::rc::Rc;
use gpui::prelude::FluentBuilder;
use gpui::{App, IntoElement, ParentElement, RenderOnce, Styled, Window, div};
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::{IconName, Selectable, h_flex};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SidebarTab {
Recents,
Chats,
Communities,
}
impl SidebarTab {
pub const ALL: [SidebarTab; 3] = [Self::Recents, Self::Chats, Self::Communities];
pub fn label(self) -> &'static str {
match self {
Self::Recents => "Recents",
Self::Chats => "Chats",
Self::Communities => "Communities",
}
}
pub fn icon(self) -> IconName {
match self {
Self::Recents => IconName::History,
Self::Chats => IconName::Message,
Self::Communities => IconName::Group,
}
}
pub fn list_id(self) -> &'static str {
match self {
Self::Recents => "sidebar-recents",
Self::Chats => "sidebar-chats",
Self::Communities => "sidebar-communities",
}
}
pub fn index(self) -> usize {
match self {
Self::Recents => 0,
Self::Chats => 1,
Self::Communities => 2,
}
}
}
#[derive(IntoElement)]
#[allow(clippy::type_complexity)]
pub struct TabBar {
active: SidebarTab,
on_select: Option<Rc<dyn Fn(SidebarTab, &mut Window, &mut App)>>,
}
impl TabBar {
pub fn new(active: SidebarTab) -> Self {
Self {
active,
on_select: None,
}
}
pub fn on_select(
mut self,
handler: impl Fn(SidebarTab, &mut Window, &mut App) + 'static,
) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
}
impl RenderOnce for TabBar {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let Self { active, on_select } = self;
div().absolute().bottom_2().left_0().w_full().px_2().child(
h_flex()
.w_full()
.p_1()
.gap_1()
.rounded(cx.theme().radius_lg)
.bg(cx.theme().elevated_surface_background)
.when(cx.theme().shadow, |this| this.shadow_md())
.children(SidebarTab::ALL.into_iter().map(|tab| {
let on_select = on_select.clone();
Button::new(format!("tab-{}", tab.list_id()))
.icon(tab.icon())
.ghost()
.flex_1()
.selected(tab == active)
.tooltip(tab.label())
.on_click(move |_event, window, cx| {
if let Some(on_select) = on_select.as_ref() {
on_select(tab, window, cx);
}
})
})),
)
}
}
+1 -41
View File
@@ -12,38 +12,8 @@ use theme::ActiveTheme;
use ui::avatar::{Avatar, PixelAvatar};
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, h_flex};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum TreeSection {
Community,
Messages,
}
impl TreeSection {
pub fn label(self) -> &'static str {
match self {
Self::Community => "Community",
Self::Messages => "Messages",
}
}
pub fn key(self) -> &'static str {
match self {
Self::Community => "community",
Self::Messages => "messages",
}
}
pub fn from_key(key: &str) -> Option<Self> {
match key {
"community" => Some(Self::Community),
"messages" => Some(Self::Messages),
_ => None,
}
}
}
pub enum SidebarRow {
Section { section: TreeSection, count: usize },
Section { label: SharedString, count: usize },
Room { room: Entity<Room> },
Community { community: Entity<Community> },
Hint { text: SharedString },
@@ -64,7 +34,6 @@ pub struct TreeRow {
label: SharedString,
avatar: Option<SharedString>,
picture: Option<ImageSource>,
caret: Option<IconName>,
count: Option<usize>,
created_at: Option<SharedString>,
selected: bool,
@@ -84,7 +53,6 @@ impl TreeRow {
label: label.into(),
avatar: None,
picture: None,
caret: None,
count: None,
created_at: None,
selected: false,
@@ -92,11 +60,6 @@ impl TreeRow {
}
}
pub fn caret(mut self, caret: IconName) -> Self {
self.caret = Some(caret);
self
}
/// Sets the seed for the row's generated avatar.
pub fn avatar(mut self, seed: impl Into<SharedString>) -> Self {
self.avatar = Some(seed.into());
@@ -221,9 +184,6 @@ impl RenderOnce for TreeRow {
)
}),
)
.when_some(self.caret, |this, caret| {
this.child(Icon::new(caret).small().text_color(cx.theme().icon_muted))
})
.when_some(self.on_click, |this, handler| {
this.cursor_pointer()
.when(!is_section, |this| {