feat: replace sidebar tabs with a single collapsible list

This commit is contained in:
2026-10-04 16:19:37 +07:00
parent 2fc1358530
commit acb07126c9
4 changed files with 239 additions and 233 deletions
+3
View File
@@ -10,6 +10,7 @@ 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::nav::Nav;
use crate::nav_item::NavItem;
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.
@@ -44,6 +45,8 @@ impl DropdownMenu for Avatar {}
impl DropdownMenu for Nav {} impl DropdownMenu for Nav {}
impl DropdownMenu for NavItem {}
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct DropdownMenuPopover<T: Selectable + IntoElement + 'static> { pub struct DropdownMenuPopover<T: Selectable + IntoElement + 'static> {
id: ElementId, id: ElementId,
+43 -6
View File
@@ -2,23 +2,26 @@ 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;
use crate::{StyledExt, h_flex}; use crate::{Selectable, StyledExt, h_flex};
/// A single navigation entry in a sidebar. /// A single navigation entry in a sidebar.
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct NavItem { pub struct NavItem {
id: ElementId, id: ElementId,
interactivity: Interactivity,
style: StyleRefinement, style: StyleRefinement,
icon: AnyElement, icon: AnyElement,
label: SharedString, label: SharedString,
suffix: Option<AnyElement>, suffix: Option<AnyElement>,
selected: bool,
clickable: bool,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>, on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
} }
@@ -28,16 +31,30 @@ impl NavItem {
label: impl Into<SharedString>, label: impl Into<SharedString>,
icon: impl IntoElement, icon: impl IntoElement,
) -> Self { ) -> 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(),
icon: icon.into_any_element(), icon: icon.into_any_element(),
label: label.into(), label: label.into(),
suffix: None, suffix: None,
selected: false,
clickable: false,
on_click: None, on_click: None,
} }
} }
/// Marks the item as clickable even when it has no `on_click` handler, so it
/// still shows the hover affordance. Used by items whose click opens a menu.
pub fn clickable(mut self, clickable: bool) -> Self {
self.clickable = clickable;
self
}
pub fn suffix(mut self, suffix: impl IntoElement) -> Self { pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
self.suffix = Some(suffix.into_any_element()); self.suffix = Some(suffix.into_any_element());
self self
@@ -52,6 +69,23 @@ impl NavItem {
} }
} }
impl Selectable for NavItem {
fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
fn is_selected(&self) -> bool {
self.selected
}
}
impl InteractiveElement for NavItem {
fn interactivity(&mut self) -> &mut Interactivity {
&mut self.interactivity
}
}
impl Styled for NavItem { impl Styled for NavItem {
fn style(&mut self) -> &mut StyleRefinement { fn style(&mut self) -> &mut StyleRefinement {
&mut self.style &mut self.style
@@ -60,7 +94,7 @@ impl Styled for NavItem {
impl RenderOnce for NavItem { impl RenderOnce for NavItem {
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;
h_flex() h_flex()
.id(self.id) .id(self.id)
@@ -86,6 +120,9 @@ impl RenderOnce for NavItem {
.when(clickable, |this| { .when(clickable, |this| {
this.cursor_pointer() this.cursor_pointer()
.hover(|this| this.bg(cx.theme().ghost_element_hover)) .hover(|this| this.bg(cx.theme().ghost_element_hover))
.when(self.selected, |this| {
this.bg(cx.theme().ghost_element_active)
})
}) })
.when_some(self.on_click, |this, handler| { .when_some(self.on_click, |this, handler| {
this.on_click(move |event, window, cx| handler(event, window, cx)) this.on_click(move |event, window, cx| handler(event, window, cx))
+193 -189
View File
@@ -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, ClipboardItem, Context, Div, Entity, EventEmitter, FocusHandle, Anchor, AnyElement, App, AppContext, ClipboardItem, Context, Div, Entity, EventEmitter,
Focusable, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString, FocusHandle, Focusable, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render,
Stateful, Styled, StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, SharedString, Stateful, Styled, StyledImage, Subscription, Task, UniformListScrollHandle,
div, img, px, relative, retain_all, uniform_list, WeakEntity, Window, 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::*;
@@ -30,8 +30,6 @@ use ui::nav::Nav;
use ui::nav_item::NavItem; use ui::nav_item::NavItem;
use ui::notification::Notification; use ui::notification::Notification;
use ui::scroll::Scrollbar; use ui::scroll::Scrollbar;
use ui::tab::Tab;
use ui::tab::tab_bar::TabBar;
use ui::{ use ui::{
Disableable, Icon, IconName, Selectable, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, Disableable, Icon, IconName, Selectable, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING,
WindowExtension, h_flex, title_bar_drag_handlers, v_flex, WindowExtension, h_flex, title_bar_drag_handlers, v_flex,
@@ -40,19 +38,34 @@ use ui::{
use crate::Command; use crate::Command;
use crate::dialogs::{import, profile, screening}; use crate::dialogs::{import, profile, screening};
mod tab;
mod utils; mod utils;
use tab::SidebarTab;
pub(crate) use utils::pick_banner; pub(crate) use utils::pick_banner;
/// A collapsible section of the sidebar's main list.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ListSection {
Chats,
Groups,
}
impl ListSection {
fn label(self) -> &'static str {
match self {
Self::Chats => "Chats",
Self::Groups => "Groups",
}
}
}
pub enum SidebarRow { pub enum SidebarRow {
Section(ListSection),
Room { room: Entity<Room> }, Room { room: Entity<Room> },
Group { group: Entity<Group> }, Group { group: Entity<Group> },
Contact { public_key: PublicKey }, Contact { public_key: PublicKey },
} }
/// A collapsible section of the group view that replaces the tab list. /// A collapsible section of the group view.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GroupSection { enum GroupSection {
Admins, Admins,
@@ -79,15 +92,14 @@ enum GroupRow {
pub struct Sidebar { pub struct Sidebar {
focus_handle: FocusHandle, focus_handle: FocusHandle,
scroll_handles: [UniformListScrollHandle; 2], list_scroll: UniformListScrollHandle,
dock: WeakEntity<DockArea>, dock: WeakEntity<DockArea>,
/// Random background shown behind the signed-out screen. /// Random background shown behind the signed-out screen.
banner: SharedString, banner: SharedString,
active_tab: SidebarTab,
/// The group whose information the sidebar is browsing, if any. /// The group whose information the sidebar is browsing, if any.
group: Option<WeakEntity<Group>>, group: Option<WeakEntity<Group>>,
/// Whether the sidebar is showing the group's information instead of its tabs. /// Whether the sidebar is showing the group's information instead of its list.
group_open: bool, group_open: bool,
/// Scroll state of the group's member list. /// Scroll state of the group's member list.
group_scroll: UniformListScrollHandle, group_scroll: UniformListScrollHandle,
@@ -96,7 +108,12 @@ pub struct Sidebar {
/// Whether the group view's members section is expanded. /// Whether the group view's members section is expanded.
members_open: bool, members_open: bool,
/// Whether the inbox list is showing contacts to start a chat with. /// Whether the main list's chats section is expanded.
chats_open: bool,
/// Whether the main list's groups section is expanded.
groups_open: bool,
/// Whether the main list is showing contacts to start a chat with.
contacts_open: bool, contacts_open: bool,
/// The signed-in user's contacts, loaded when the contact picker opens. /// The signed-in user's contacts, loaded when the contact picker opens.
contacts: Option<Vec<PublicKey>>, contacts: Option<Vec<PublicKey>>,
@@ -105,11 +122,10 @@ pub struct Sidebar {
/// Scroll state of the contact picker's list. /// Scroll state of the contact picker's list.
contacts_scroll: UniformListScrollHandle, contacts_scroll: UniformListScrollHandle,
/// Whether the inbox list is showing chat requests instead of rooms. /// Whether the main list is showing chat requests instead of chats and groups.
requests_open: bool, requests_open: bool,
/// Scroll state of the requests list.
requests_scroll: UniformListScrollHandle,
new_requests: bool, new_requests: bool,
requests_scroll: UniformListScrollHandle,
tasks: SmallVec<[Task<Result<(), Error>>; 1]>, tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
_subscriptions: SmallVec<[Subscription; 4]>, _subscriptions: SmallVec<[Subscription; 4]>,
@@ -159,18 +175,16 @@ impl Sidebar {
Self { Self {
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
scroll_handles: [ list_scroll: UniformListScrollHandle::new(),
UniformListScrollHandle::new(),
UniformListScrollHandle::new(),
],
dock, dock,
banner: pick_banner(), banner: pick_banner(),
active_tab: SidebarTab::Inbox,
group: None, group: None,
group_open: false, group_open: false,
group_scroll: UniformListScrollHandle::new(), group_scroll: UniformListScrollHandle::new(),
admins_open: true, admins_open: true,
members_open: true, members_open: true,
chats_open: true,
groups_open: true,
contacts_open: false, contacts_open: false,
contacts: None, contacts: None,
selected_contacts: HashSet::new(), selected_contacts: HashSet::new(),
@@ -183,7 +197,7 @@ impl Sidebar {
} }
} }
/// Show the group view, replacing the tab list. /// Show the group view, replacing the main list.
fn show_group(&mut self, group: &Entity<Group>, cx: &mut Context<Self>) { fn show_group(&mut self, group: &Entity<Group>, cx: &mut Context<Self>) {
self.group = Some(group.downgrade()); self.group = Some(group.downgrade());
self.group_open = true; self.group_open = true;
@@ -214,7 +228,7 @@ impl Sidebar {
} }
} }
/// Leave the group view, returning the sidebar to its tab list. /// Leave the group view, returning the sidebar to its main list.
fn hide_group(&mut self, cx: &mut Context<Self>) { fn hide_group(&mut self, cx: &mut Context<Self>) {
if !self.group_open { if !self.group_open {
return; return;
@@ -224,16 +238,6 @@ impl Sidebar {
cx.notify(); cx.notify();
} }
fn select_tab(&mut self, tab: SidebarTab, cx: &mut Context<Self>) {
if self.active_tab == tab {
return;
}
self.active_tab = tab;
self.close_contacts();
self.close_requests();
cx.notify();
}
/// Leave the contact picker and clear its selection. /// Leave the contact picker and clear its selection.
fn close_contacts(&mut self) { fn close_contacts(&mut self) {
self.contacts_open = false; self.contacts_open = false;
@@ -508,26 +512,20 @@ impl Sidebar {
.into_any_element() .into_any_element()
} }
fn render_tabs(&mut self, cx: &mut Context<Self>) -> AnyElement { fn render_list(&mut self, cx: &mut Context<Self>) -> AnyElement {
let sidebar = cx.entity().downgrade(); let requests_open = self.requests_open;
let active_tab = self.active_tab; let contacts_open = self.contacts_open && !requests_open;
let requests_open = self.requests_open && active_tab == SidebarTab::Inbox; let rows = Rc::new(self.rows_for(cx));
let contacts_open = self.contacts_open && active_tab == SidebarTab::Inbox && !requests_open;
let rows = Rc::new(self.rows_for(active_tab, cx));
// Whether to show the create button for the active tab // Whether to show the create button for the contact picker
let create_button = contacts_open && !self.selected_contacts.is_empty(); let create_button = contacts_open && !self.selected_contacts.is_empty();
let (list_id, scroll_handle, list_title) = if contacts_open { let (list_id, scroll_handle, list_title) = if contacts_open {
("sidebar-contacts", &self.contacts_scroll, "Contacts") ("sidebar-contacts", &self.contacts_scroll, Some("Contacts"))
} else if requests_open { } else if requests_open {
("sidebar-requests", &self.requests_scroll, "Requests") ("sidebar-requests", &self.requests_scroll, Some("Requests"))
} else { } else {
( ("sidebar-list", &self.list_scroll, None)
active_tab.list_id(),
&self.scroll_handles[active_tab.index()],
active_tab.list_title(),
)
}; };
let request_count = ChatRegistry::global(cx) let request_count = ChatRegistry::global(cx)
@@ -541,127 +539,93 @@ impl Sidebar {
.relative() .relative()
.gap_2() .gap_2()
.child( .child(
div().px_2().child( v_flex()
TabBar::new("sidebar-tabs") .px_2()
.segmented(true) .gap_1()
.selected_index(active_tab.index()) .child(
.child(Tab::new().label(SidebarTab::Inbox.label())) NavItem::new("new", "New", Icon::new(IconName::PlusCircle).small())
.child(Tab::new().label(SidebarTab::Groups.label())) .clickable(true)
.on_click({ .dropdown_menu_with_anchor(Anchor::TopRight, |menu, _window, _cx| {
let sidebar = sidebar.clone(); menu.menu_with_icon(
move |index, _window, cx| { "New Chat",
let Some(tab) = SidebarTab::ALL.get(*index).copied() else { IconName::Message,
return; Box::new(Command::NewChat),
}; )
if let Err(error) = .menu_with_icon(
sidebar.update(cx, |this, cx| this.select_tab(tab, cx)) "New Group",
{ IconName::Group,
log::error!("Failed to switch sidebar tab: {error}"); Box::new(Command::NewGroup),
} )
} .separator()
}), .menu_with_icon(
), "Join Group",
) IconName::Door,
.map(|this| match active_tab { Box::new(Command::JoinGroup),
SidebarTab::Inbox => this.child( )
v_flex() }),
.px_2() )
.gap_1() .child(
.child( NavItem::new("browse", "Browse", Icon::new(IconName::Compass).small())
NavItem::new("chat", "New Chat", Icon::new(IconName::Message).small()) .on_click(|_, window, cx| {
.on_click(|_event, window, cx| { window.dispatch_action(Box::new(Command::ShowBrowse), cx)
window.dispatch_action(Box::new(Command::NewChat), cx) }),
}), )
) .child(
.child( NavItem::new("reqs", "Requests", Icon::new(IconName::Invite).small())
NavItem::new("reqs", "Requests", Icon::new(IconName::Invite).small()) .when(request_count > 0, |this| {
.when(request_count > 0, |this| { this.suffix(
this.suffix( h_flex()
h_flex() .gap_1()
.gap_1() .items_center()
.items_center() .child(
.child( h_flex()
h_flex() .py_0p5()
.py_0p5() .px_1()
.px_1() .min_w_6()
.min_w_6() .justify_center()
.justify_center() .text_size(px(10.))
.text_size(px(10.)) .text_color(cx.theme().text_muted)
.text_color(cx.theme().text_muted) .text_center()
.text_center() .rounded(cx.theme().radius)
.rounded(cx.theme().radius) .bg(cx.theme().elevated_surface_background)
.bg(cx.theme().elevated_surface_background) .child(SharedString::from(
.child(SharedString::from( request_count.to_string(),
request_count.to_string(), )),
)), )
.when(self.new_requests && !requests_open, |this| {
this.child(
div().size_1().rounded_full().bg(cx.theme().cursor),
) )
.when(self.new_requests && !requests_open, |this| { }),
this.child( )
div() })
.size_1() .when(requests_open, |this| {
.rounded_full() this.bg(cx.theme().ghost_element_active)
.bg(cx.theme().cursor), })
) .on_click(cx.listener(|this, _event, _window, cx| {
}), this.toggle_requests(cx);
) })),
}) )
.when(requests_open, |this| { .child(
this.bg(cx.theme().ghost_element_active) NavItem::new("contacts", "Contacts", Icon::new(IconName::Book).small())
}) .when(contacts_open, |this| {
.on_click(cx.listener(|this, _event, _window, cx| { this.bg(cx.theme().ghost_element_active)
this.toggle_requests(cx); })
})), .on_click(cx.listener(|this, _event, window, cx| {
) this.toggle_contacts(window, cx);
.child( })),
NavItem::new("contacts", "Contacts", Icon::new(IconName::Book).small()) ),
.when(contacts_open, |this| {
this.bg(cx.theme().ghost_element_active)
})
.on_click(cx.listener(|this, _event, window, cx| {
this.toggle_contacts(window, cx);
})),
),
),
SidebarTab::Groups => this.child(
v_flex()
.px_2()
.gap_1()
.child(
NavItem::new(
"new-group",
"New Group",
Icon::new(IconName::Group).small(),
)
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(Command::NewGroup), cx)
}),
)
.child(
NavItem::new(
"join-group",
"Join Group",
Icon::new(IconName::Door).small(),
)
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(Command::JoinGroup), cx)
}),
)
.child(
NavItem::new("browse", "Browse", Icon::new(IconName::Compass).small())
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(Command::ShowBrowse), cx)
}),
),
),
})
.child(
div()
.px_4()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_placeholder)
.child(list_title),
) )
.when_some(list_title, |this, list_title| {
this.child(
div()
.px_4()
.text_xs()
.font_semibold()
.text_color(cx.theme().text_placeholder)
.child(list_title),
)
})
.child( .child(
div() div()
.min_h_0() .min_h_0()
@@ -720,42 +684,54 @@ impl Sidebar {
.into_any_element() .into_any_element()
} }
fn rows_for(&self, tab: SidebarTab, cx: &App) -> Vec<SidebarRow> { fn rows_for(&self, cx: &App) -> Vec<SidebarRow> {
match tab { if self.contacts_open {
SidebarTab::Inbox if self.contacts_open => self return self
.contacts .contacts
.clone() .clone()
.unwrap_or_default() .unwrap_or_default()
.into_iter() .into_iter()
.map(|public_key| SidebarRow::Contact { public_key }) .map(|public_key| SidebarRow::Contact { public_key })
.collect(), .collect();
SidebarTab::Inbox if self.requests_open => { }
let chat = ChatRegistry::global(cx);
chat.read(cx) if self.requests_open {
.rooms(&RoomKind::Request, cx) let chat = ChatRegistry::global(cx);
.into_iter() return chat
.map(|room| SidebarRow::Room { room }) .read(cx)
.collect() .rooms(&RoomKind::Request, cx)
} .into_iter()
SidebarTab::Inbox => { .map(|room| SidebarRow::Room { room })
let chat = ChatRegistry::global(cx); .collect();
}
let mut rows = Vec::new();
let chat = ChatRegistry::global(cx);
rows.push(SidebarRow::Section(ListSection::Chats));
if self.chats_open {
rows.extend(
chat.read(cx) chat.read(cx)
.rooms(&RoomKind::Ongoing, cx) .rooms(&RoomKind::Ongoing, cx)
.into_iter() .into_iter()
.map(|room| SidebarRow::Room { room }) .map(|room| SidebarRow::Room { room }),
.collect() );
} }
SidebarTab::Groups => {
let registry = GroupsRegistry::global(cx); let registry = GroupsRegistry::global(cx);
rows.push(SidebarRow::Section(ListSection::Groups));
if self.groups_open {
rows.extend(
registry registry
.read(cx) .read(cx)
.groups() .groups()
.iter() .iter()
.cloned() .cloned()
.map(|group| SidebarRow::Group { group }) .map(|group| SidebarRow::Group { group }),
.collect() );
}
} }
rows
} }
fn render_rows( fn render_rows(
@@ -774,6 +750,34 @@ impl Sidebar {
let index = range.start + offset; let index = range.start + offset;
match row { match row {
SidebarRow::Section(section) => {
let section = *section;
let open = match section {
ListSection::Chats => self.chats_open,
ListSection::Groups => self.groups_open,
};
let icon = if open {
IconName::CaretDown
} else {
IconName::CaretRight
};
let label = section.label();
Nav::new(label)
.label(label)
.suffix(Icon::new(icon).small().text_color(cx.theme().icon_muted))
.text_xs()
.font_semibold()
.text_color(cx.theme().text_placeholder)
.on_click(cx.listener(move |this, _event, _window, cx| {
match section {
ListSection::Chats => this.chats_open = !this.chats_open,
ListSection::Groups => this.groups_open = !this.groups_open,
}
cx.notify();
}))
.into_any_element()
}
SidebarRow::Room { room } => { SidebarRow::Room { room } => {
let name = room.read(cx).display_name(cx); let name = room.read(cx).display_name(cx);
let picture = room.read(cx).display_image(cx); let picture = room.read(cx).display_image(cx);
@@ -1261,7 +1265,7 @@ impl Render for Sidebar {
}) })
.map(|this| match group { .map(|this| match group {
Some(group) if self.group_open => this.child(self.render_group(group, cx)), Some(group) if self.group_open => this.child(self.render_group(group, cx)),
_ => this.child(self.render_tabs(cx)), _ => this.child(self.render_list(cx)),
}) })
.when(loading && logged_in, |this| { .when(loading && logged_in, |this| {
this.child( this.child(
-38
View File
@@ -1,38 +0,0 @@
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SidebarTab {
Inbox,
Groups,
}
impl SidebarTab {
pub const ALL: [SidebarTab; 2] = [Self::Inbox, Self::Groups];
pub fn label(self) -> &'static str {
match self {
Self::Inbox => "Inbox",
Self::Groups => "Groups",
}
}
/// Heading for the tab's list of items.
pub fn list_title(self) -> &'static str {
match self {
Self::Inbox => "Direct Messages",
Self::Groups => "Groups",
}
}
pub fn list_id(self) -> &'static str {
match self {
Self::Inbox => "sidebar-inbox",
Self::Groups => "sidebar-groups",
}
}
pub fn index(self) -> usize {
match self {
Self::Inbox => 0,
Self::Groups => 1,
}
}
}