diff --git a/assets/icons/history.svg b/assets/icons/history.svg
new file mode 100644
index 00000000..e7c76e68
--- /dev/null
+++ b/assets/icons/history.svg
@@ -0,0 +1,3 @@
+
diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs
index 73ca64ae..2e3b53d8 100644
--- a/crates/settings/src/lib.rs
+++ b/crates/settings/src/lib.rs
@@ -46,7 +46,6 @@ setting_accessors! {
pub nip4e: bool,
pub trusted_relays: Vec,
pub file_server: Url,
- pub expanded_sections: Option>,
}
/// 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>,
}
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,
}
}
}
diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs
index 5b9c17cb..9b935db8 100644
--- a/crates/ui/src/icon.rs
+++ b/crates/ui/src/icon.rs
@@ -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",
diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs
index db5342b2..5d1c05da 100644
--- a/crates/workspace/src/sidebar/mod.rs
+++ b/crates/workspace/src/sidebar/mod.rs
@@ -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,
_subscriptions: SmallVec<[Subscription; 2]>,
}
impl Sidebar {
pub fn new(window: &mut Window, cx: &mut Context) -> 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) {
- if !self.expanded.remove(§ion) {
- self.expanded.insert(section);
- }
-
- self.save_expanded(cx);
- cx.notify();
- }
-
- fn is_expanded(&self, section: TreeSection) -> bool {
- self.expanded.contains(§ion)
- }
-
- fn restore_state(&mut self, cx: &mut Context) {
- let expanded = load_expanded(cx);
- if self.expanded == expanded {
+ fn select_tab(&mut self, tab: SidebarTab, cx: &mut Context) {
+ 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 {
- 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,
- rows: &[SidebarRow],
- cx: &Context,
- ) -> Vec {
- 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) -> 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 {
- let Some(keys) = AppSettings::get_expanded_sections(cx) else {
- return BTreeSet::from([TreeSection::Community, TreeSection::Messages]);
- };
+fn rows_for(tab: SidebarTab, cx: &App) -> Vec {
+ 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, rows: &[SidebarRow], cx: &Context) -> Vec {
+ 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()
diff --git a/crates/workspace/src/sidebar/tab.rs b/crates/workspace/src/sidebar/tab.rs
new file mode 100644
index 00000000..1ce5ecc4
--- /dev/null
+++ b/crates/workspace/src/sidebar/tab.rs
@@ -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>,
+}
+
+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);
+ }
+ })
+ })),
+ )
+ }
+}
diff --git a/crates/workspace/src/sidebar/tree.rs b/crates/workspace/src/sidebar/tree.rs
index eef4b17c..7b8fc465 100644
--- a/crates/workspace/src/sidebar/tree.rs
+++ b/crates/workspace/src/sidebar/tree.rs
@@ -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 {
- 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 },
Community { community: Entity },
Hint { text: SharedString },
@@ -64,7 +34,6 @@ pub struct TreeRow {
label: SharedString,
avatar: Option,
picture: Option,
- caret: Option,
count: Option,
created_at: Option,
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) -> 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| {