diff --git a/crates/auto_update/src/lib.rs b/crates/auto_update/src/lib.rs index 92b4c63d..bf094871 100644 --- a/crates/auto_update/src/lib.rs +++ b/crates/auto_update/src/lib.rs @@ -31,7 +31,7 @@ fn uses_managed_updates() -> bool { // Allow opting out of in-app updates via an explicit environment variable. || std::env::var(COOP_UPDATE_EXPLANATION).is_ok() // The Snap package sets `COOP_BUNDLE_TYPE=snap` (see snapcraft.yaml.in). - || std::env::var(COOP_BUNDLE_TYPE).is_ok_and(|value| value == "snap") + || std::env::var(COOP_BUNDLE_TYPE).is_ok_and(|value | value == "snap") } /// Initialize the auto-update system. @@ -129,6 +129,11 @@ impl AutoUpdater { matches!(self.status, UpdateStatus::Idle) } + /// Whether the running version is the newest release, so the status line can be hidden. + pub fn up_to_date(&self) -> bool { + matches!(self.status, UpdateStatus::Idle | UpdateStatus::UpToDate) + } + /// Whether a verified update is installed and waiting for a restart. pub fn staged(&self) -> bool { matches!(self.status, UpdateStatus::Staged(_)) diff --git a/crates/ui/src/tab/mod.rs b/crates/ui/src/tab/mod.rs index 812927df..c76a9ac3 100644 --- a/crates/ui/src/tab/mod.rs +++ b/crates/ui/src/tab/mod.rs @@ -25,6 +25,7 @@ pub struct Tab { children: Vec, pub(super) disabled: bool, pub(super) selected: bool, + pub(super) segmented: bool, on_click: Option>, } @@ -69,6 +70,7 @@ impl Default for Tab { children: Vec::new(), disabled: false, selected: false, + segmented: false, prefix: None, suffix: None, on_click: None, @@ -132,6 +134,12 @@ impl Tab { self.tab_bar_prefix = Some(tab_bar_prefix); self } + + /// Render the tab as a segment inside a segmented control. + pub(crate) fn segmented(mut self, segmented: bool) -> Self { + self.segmented = segmented; + self + } } impl ParentElement for Tab { @@ -167,74 +175,111 @@ impl Styled for Tab { impl RenderOnce for Tab { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - let fg = if self.disabled { + let Self { + ix, + base, + label, + icon, + prefix, + suffix, + children, + disabled, + selected, + segmented, + on_click, + .. + } = self; + + let fg = if disabled { cx.theme().text_muted - } else if self.selected { + } else if selected { cx.theme().tab_active_foreground } else { cx.theme().tab_foreground }; - self.base - .id(self.ix) - .flex() - .flex_wrap() - .gap_1() + let content = h_flex() + .flex_1() + .map(|this| { + if segmented { + this.h(px(24.)) + } else { + this.h(px(30.)) + } + }) + .line_height(relative(1.)) + .whitespace_nowrap() .items_center() - .flex_shrink_0() - .h(TABBAR_HEIGHT) - .relative() + .justify_center() .overflow_hidden() + .when(segmented, |this| this.px_1()) + .when(!segmented, |this| this.flex_shrink_0().px_3()) + .map(|this| match icon { + Some(icon) => this.w(px(38.)).child(icon.size_4()), + None => this + .map(|this| match label { + Some(label) => this.child(label), + None => this, + }) + .children(children), + }); + + base.id(ix) + .flex() + .items_center() .text_color(fg) - .text_sm() - .when_some(self.prefix, |this, prefix| this.child(prefix)) - .child( - h_flex() + .when(segmented, |this| { + this.text_xs() .flex_1() - .h(px(30.)) - .line_height(relative(1.)) - .whitespace_nowrap() - .items_center() - .justify_center() - .overflow_hidden() + .h(px(24.)) + .rounded(cx.theme().radius) + .when(selected && !disabled, |this| { + this.bg(cx.theme().tab_active_background) + .when(cx.theme().shadow, |this| this.shadow_sm()) + }) + .when(!selected && !disabled, |this| { + this.hover(|this| this.bg(cx.theme().tab_hover_background)) + }) + }) + .when(!segmented, |this| { + this.text_sm() + .flex_wrap() + .gap_1() .flex_shrink_0() - .px_3() - .map(|this| match self.icon { - Some(icon) => this.w(px(38.)).child(icon.size_4()), - None => this - .map(|this| match self.label { - Some(label) => this.child(label), - None => this, - }) - .children(self.children), - }), - ) - .when_some(self.suffix, |this, suffix| { + .h(TABBAR_HEIGHT) + .relative() + .overflow_hidden() + }) + .when_some(prefix, |this, prefix| this.child(prefix)) + .child(content) + .when_some(suffix, |this, suffix| { this.child(div().pr_2().child(suffix)) }) .on_mouse_down(MouseButton::Left, |_ev, _window, cx| { cx.stop_propagation(); }) - .when(!self.disabled, |this| { - this.when_some(self.on_click.clone(), |this, on_click| { + .when(!disabled, |this| { + this.when_some(on_click, |this, on_click| { this.on_click(move |event, window, cx| on_click(event, window, cx)) }) }) - .child( - div() - .absolute() - .bottom_0() - .left_0() - .right_0() - .h_0p5() - .when(self.selected && !self.disabled, |this| { - this.bg(cx.theme().element_active) - }) - .when(!self.selected && !self.disabled, |this| { - this.invisible().group_hover("", |this| { - this.visible().bg(cx.theme().secondary_background) + .when(!segmented, |this| { + this.child( + div() + .absolute() + .bottom_0() + .left_0() + .right_0() + .h_0p5() + .when(selected && !disabled, |this| { + this.bg(cx.theme().element_active) }) - }), - ) + .when(!selected && !disabled, |this| { + this.invisible().group_hover("", |this| { + this.visible().bg(cx.theme().secondary_background) + }) + }), + ) + }) } } diff --git a/crates/ui/src/tab/tab_bar.rs b/crates/ui/src/tab/tab_bar.rs index 11a6463e..b86863e5 100644 --- a/crates/ui/src/tab/tab_bar.rs +++ b/crates/ui/src/tab/tab_bar.rs @@ -7,6 +7,7 @@ use gpui::{ Window, div, px, }; use smallvec::SmallVec; +use theme::ActiveTheme; use super::Tab; use crate::button::{Button, ButtonVariants as _}; @@ -25,6 +26,7 @@ pub struct TabBar { last_empty_space: AnyElement, selected_index: Option, menu: bool, + segmented: bool, #[allow(clippy::type_complexity)] on_click: Option>, } @@ -43,9 +45,16 @@ impl TabBar { selected_index: None, on_click: None, menu: false, + segmented: false, } } + /// Render the tabs as a segmented control inside a pill-shaped track. + pub fn segmented(mut self, segmented: bool) -> Self { + self.segmented = segmented; + self + } + /// Set whether to show the menu button when tabs overflow, default is false. pub fn menu(mut self, menu: bool) -> Self { self.menu = menu; @@ -113,10 +122,11 @@ impl Styled for TabBar { } impl RenderOnce for TabBar { - fn render(self, _: &mut Window, _cx: &mut App) -> impl IntoElement { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let mut item_labels = Vec::new(); let selected_index = self.selected_index; let on_click = self.on_click.clone(); + let segmented = self.segmented; self.base .group("tab-bar") @@ -124,22 +134,28 @@ impl RenderOnce for TabBar { .flex() .items_center() .refine_style(&self.style) + .when(segmented, |this| { + this.bg(cx.theme().tab_background) + .p_0p5() + .rounded(cx.theme().radius) + }) .when_some(self.prefix, |this, prefix| this.child(prefix)) .child( h_flex() .id("tabs") .flex_1() - .overflow_x_scroll() + .when(!segmented, |this| this.overflow_x_scroll()) .when_some(self.scroll_handle, |this, scroll_handle| { this.track_scroll(&scroll_handle) }) - .gap(px(0.)) + .gap_1() .children(self.children.into_iter().enumerate().map(|(ix, child)| { item_labels.push((child.label.clone(), child.disabled)); let tab_bar_prefix = child.tab_bar_prefix.unwrap_or(true); child .ix(ix) .tab_bar_prefix(tab_bar_prefix) + .segmented(segmented) .when_some(self.selected_index, |this, selected_ix| { this.selected(selected_ix == ix) }) diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index ef937d44..81139445 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -2,19 +2,18 @@ use std::ops::Range; use std::rc::Rc; use auto_update::AutoUpdater; -use chat::{ChatEvent, ChatRegistry, Room, RoomKind}; +use chat::{ChatEvent, ChatRegistry, RoomKind}; use common::TimestampExt; use community::{ChannelId, Community, CommunityEvent, CommunityRegistry}; use gpui::prelude::FluentBuilder; use gpui::{ AnyElement, App, Context, Div, ElementId, Entity, EventEmitter, FocusHandle, Focusable, - InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, ScrollHandle, SharedString, - Stateful, StatefulInteractiveElement, Styled, StyledImage, Subscription, - UniformListScrollHandle, WeakEntity, Window, div, img, px, retain_all, uniform_list, + InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString, Stateful, + Styled, StyledImage, Subscription, UniformListScrollHandle, WeakEntity, Window, div, img, px, + retain_all, uniform_list, }; use nostr_sdk::prelude::*; use person::PersonRegistry; -use settings::AppSettings; use smallvec::{SmallVec, smallvec}; use state::NostrRegistry; use theme::{ActiveTheme, TABBAR_HEIGHT}; @@ -26,9 +25,11 @@ use ui::menu::{DropdownMenu, PopupMenuItem}; use ui::nav_item::NavItem; use ui::notification::Notification; use ui::scroll::Scrollbar; +use ui::tab::Tab; +use ui::tab::tab_bar::TabBar; use ui::{ - Icon, IconName, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, WindowExtension, h_flex, - title_bar_drag_handlers, v_flex, + Disableable, Icon, IconName, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, WindowExtension, + h_flex, title_bar_drag_handlers, v_flex, }; use crate::Command; @@ -37,15 +38,15 @@ use crate::dialogs::import; mod tab; mod tree; -use tab::{SidebarTab, TabBar}; -use tree::SidebarRow; +use tab::SidebarTab; +use tree::{CommunityRow, CommunitySection, SidebarRow}; pub(crate) use tree::{TreeRow, TreeRowKind}; pub struct Sidebar { focus_handle: FocusHandle, - scroll_handles: [UniformListScrollHandle; 3], - /// Scroll state of the channel and member lists - community_scroll: ScrollHandle, + scroll_handles: [UniformListScrollHandle; 2], + /// Scroll state of the community's channel and member lists + community_scroll: UniformListScrollHandle, /// The dock the sidebar opens its panels in dock: WeakEntity, active_tab: SidebarTab, @@ -89,11 +90,10 @@ impl Sidebar { scroll_handles: [ UniformListScrollHandle::new(), UniformListScrollHandle::new(), - UniformListScrollHandle::new(), ], - community_scroll: ScrollHandle::default(), + community_scroll: UniformListScrollHandle::new(), dock, - active_tab: SidebarTab::Recents, + active_tab: SidebarTab::Inbox, community: None, channels_open: true, admins_open: true, @@ -111,42 +111,6 @@ impl Sidebar { cx.notify(); } - fn open_community( - &mut self, - community: Entity, - window: &mut Window, - cx: &mut Context, - ) { - let id = community.read(cx).id().to_hex(); - let settings = AppSettings::global(cx); - - settings.update(cx, |settings, cx| { - settings.record_recent_community(id, cx); - }); - - self.community = Some(community.downgrade()); - - ui::dock::add_panel_to( - &self.dock, - PanelHandle::new(community_ui::init(community, window, cx)), - DockPlacement::Center, - window, - cx, - ); - - cx.notify(); - } - - fn open_room(&mut self, room: Entity, window: &mut Window, cx: &mut Context) { - ui::dock::add_panel_to( - &self.dock, - PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)), - DockPlacement::Center, - window, - cx, - ); - } - /// Leave the community view, returning the sidebar to its tab list. fn reset_community(&mut self, cx: &mut Context) { if self.community.take().is_none() { @@ -155,9 +119,12 @@ impl Sidebar { cx.notify(); } - fn render_user(&self, cx: &mut Context) -> Stateful
{ - let nostr = NostrRegistry::global(cx); - let current_user = nostr.read(cx).current_user(); + fn render_user(&self, current_user: &PublicKey, cx: &mut Context) -> Stateful
{ + let persons = PersonRegistry::global(cx); + let profile = persons.read(cx).get(current_user, cx); + let avatar = profile.avatar(); + let avatar_seed = profile.avatar_seed(); + let name = profile.name(); h_flex() .id("sidebar-user") @@ -170,88 +137,66 @@ impl Sidebar { .when(cfg!(target_os = "macos"), |this| { this.pl(px(TRAFFIC_LIGHT_PADDING)) }) - .when_some(current_user.as_ref(), |this, public_key| { - let persons = PersonRegistry::global(cx); - let profile = persons.read(cx).get(public_key, cx); - let avatar = profile.avatar(); - let avatar_seed = profile.avatar_seed(); - let name = profile.name(); + .child( + Button::new("current-user") + .child( + Avatar::new(avatar.clone()) + .seed(avatar_seed.clone()) + .xsmall(), + ) + .small() + .caret() + .compact() + .transparent() + .dropdown_menu(move |this, _window, _cx| { + let avatar = avatar.clone(); + let avatar_seed = avatar_seed.clone(); + let name = name.clone(); - this.child( - Button::new("current-user") - .child( - Avatar::new(avatar.clone()) - .seed(avatar_seed.clone()) - .xsmall(), - ) - .small() - .caret() - .compact() - .transparent() - .dropdown_menu(move |this, _window, cx| { - let avatar = avatar.clone(); - let avatar_seed = avatar_seed.clone(); - let name = name.clone(); - - this.min_w(px(256.)) - .item(PopupMenuItem::element(move |_window, cx| { - h_flex() - .gap_1p5() - .text_xs() - .text_color(cx.theme().text_muted) - .child( - Avatar::new(avatar.clone()) - .seed(avatar_seed.clone()) - .xsmall(), - ) - .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, - Box::new(Command::ShowProfile), - ) - .menu_with_icon( - "Contact List", - IconName::Book, - Box::new(Command::ShowContactList), - ) - .menu_with_icon( - "Backup", - IconName::UserKey, - Box::new(Command::ShowBackup), - ) - .menu_with_icon( - "Themes", - IconName::Sun, - Box::new(Command::ToggleTheme), - ) - .when(AutoUpdater::is_available(cx), |this| { - this.separator().menu_with_icon( - "Check for Updates", - IconName::Device, - Box::new(Command::Update), + this.min_w(px(256.)) + .item(PopupMenuItem::element(move |_window, cx| { + h_flex() + .gap_1p5() + .text_xs() + .text_color(cx.theme().text_muted) + .child( + Avatar::new(avatar.clone()) + .seed(avatar_seed.clone()) + .xsmall(), ) - }) - .menu_with_icon( - "Settings", - IconName::Settings, - Box::new(Command::ShowSettings), - ) - }), - ) - }) + .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, + Box::new(Command::ShowProfile), + ) + .menu_with_icon( + "Contact List", + IconName::Book, + Box::new(Command::ShowContactList), + ) + .menu_with_icon( + "Backup", + IconName::UserKey, + Box::new(Command::ShowBackup), + ) + .menu_with_icon("Themes", IconName::Sun, Box::new(Command::ToggleTheme)) + .separator() + .menu_with_icon( + "Settings", + IconName::Settings, + Box::new(Command::ShowSettings), + ) + }), + ) .child(div().flex_1()) .when_some(AutoUpdater::try_global(cx), |this, updater| { this.child(self.render_updater(updater, cx)) @@ -272,12 +217,21 @@ impl Sidebar { fn render_updater(&self, updater: Entity, cx: &mut App) -> AnyElement { let status = updater.read(cx).status(); + let up_to_date = updater.read(cx).up_to_date(); let staged = updater.read(cx).staged(); h_flex() .gap_2() - .text_xs() - .child(status) + .when(!up_to_date, |this| { + this.child( + Button::new("update-status") + .icon(IconName::ArrowDownCircle) + .tooltip(status) + .small() + .warning() + .disabled(true), + ) + }) .when(staged, |this| { this.child( Button::new("restart-to-update") @@ -296,9 +250,6 @@ impl Sidebar { } fn render_tabs(&mut self, cx: &mut Context) -> AnyElement { - let chat = ChatRegistry::global(cx); - let loading = chat.read(cx).loading(); - let sidebar = cx.entity().downgrade(); let active_tab = self.active_tab; let rows = Rc::new(self.rows_for(active_tab, cx)); @@ -308,90 +259,95 @@ impl Sidebar { .size_full() .flex_1() .min_h_0() - .gap_1() - .when(active_tab.chat(), |this| { - this.child( + .gap_2() + .child( + div().px_2().child( + TabBar::new("sidebar-tabs") + .segmented(true) + .selected_index(active_tab.index()) + .child(Tab::new().label(SidebarTab::Inbox.label())) + .child(Tab::new().label(SidebarTab::Communities.label())) + .on_click({ + let sidebar = sidebar.clone(); + move |index, _window, cx| { + let Some(tab) = SidebarTab::ALL.get(*index).copied() else { + return; + }; + if let Err(error) = + sidebar.update(cx, |this, cx| this.select_tab(tab, cx)) + { + log::error!("Failed to switch sidebar tab: {error}"); + } + } + }), + ), + ) + .map(|this| match active_tab { + SidebarTab::Inbox => 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::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}"); - } - window.dispatch_action(Box::new(Command::ShowRequests), cx); - } - }), - ) - .child( - NavItem::new( - "nav-new-chat", - "New chat", - Icon::new(IconName::Plus).small(), + "new-chat", + "New Chat", + Icon::new(IconName::Message).small(), ) .on_click(|_event, window, cx| { window.dispatch_action(Box::new(Command::NewChat), cx) }), + ) + .child( + NavItem::new("reqs", "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); + } + }), + ) + .child( + NavItem::new("contacts", "Contacts", Icon::new(IconName::Book).small()) + .on_click(|_event, window, cx| { + window.dispatch_action(Box::new(Command::ShowContactList), cx) + }), ), - ) - }) - .when(active_tab.community(), |this| { - this.child( + ), + SidebarTab::Communities => this.child( v_flex() .px_2() .gap_1() .child( NavItem::new( - "nav-browse", - "Browse", - Icon::new(IconName::Compass).small(), + "new-community", + "New Community", + Icon::new(IconName::Group).small(), ) - .on_click(|_event, window, cx| { - window.dispatch_action(Box::new(Command::ShowBrowse), cx) + .on_click(|_, window, cx| { + window.dispatch_action(Box::new(Command::NewCommunity), cx) }), ) .child( - NavItem::new( - "nav-new-community", - "New community", - Icon::new(IconName::Plus).small(), - ) - .on_click(|_event, window, cx| { - window.dispatch_action(Box::new(Command::NewCommunity), cx) - }), + NavItem::new("browse", "Browse", Icon::new(IconName::Compass).small()) + .on_click(|_, window, cx| { + window.dispatch_action(Box::new(Command::ShowBrowse), cx) + }), ), - ) + ), }) .child( - v_flex() - .size_full() - .flex_1() + div() .min_h_0() - .gap_1() - .pb_12() + .flex_1() .child( uniform_list( active_tab.list_id(), @@ -401,46 +357,11 @@ impl Sidebar { }), ) .track_scroll(scroll_handle) - .flex_1() .h_full() .px_2(), ) .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_16() - .left_0() - .h_9() - .w_full() - .px_8() - .child( - h_flex() - .gap_2() - .w_full() - .h_9() - .justify_center() - .bg(cx.theme().background.opacity(0.85)) - .when(cx.theme().shadow, |this| this.shadow_md()) - .rounded_full() - .text_xs() - .font_semibold() - .text_color(cx.theme().text_muted) - .child(Indicator::new().small().color(cx.theme().icon_accent)) - .child("Getting messages..."), - ), - ) - }) .into_any_element() } @@ -449,7 +370,7 @@ impl Sidebar { community: Entity, cx: &mut Context, ) -> AnyElement { - let (channels, active, admins, members, banner) = { + let (banner, rows) = { let community = community.read(cx); let owner = community.state().owner; let mut admins = Vec::new(); @@ -463,71 +384,45 @@ impl Sidebar { } } - ( - community - .channels() - .iter() - .map(|channel| (channel.id, channel.name.clone(), channel.private)) - .collect::>(), - community.active_channel(), - admins, - members, - community.banner(), - ) + let active = community.active_channel(); + let banner = community.banner(); + + let mut rows = vec![CommunityRow::Section(CommunitySection::Channels)]; + if self.channels_open { + rows.extend( + community + .channels() + .iter() + .map(|channel| CommunityRow::Channel { + id: channel.id, + name: channel.name.clone().into(), + private: channel.private, + selected: active == Some(channel.id), + }), + ); + } + + rows.push(CommunityRow::Section(CommunitySection::Admins)); + if self.admins_open { + rows.extend(admins.into_iter().map(|public_key| CommunityRow::Member { + prefix: "community-admin", + public_key, + })); + } + + rows.push(CommunityRow::Section(CommunitySection::Members)); + if self.members_open { + rows.extend(members.into_iter().map(|public_key| CommunityRow::Member { + prefix: "member", + public_key, + })); + } + + (banner, rows) }; - let sections = v_flex() - .id("community-sections") - .flex_1() - .min_h_0() - .w_full() - .px_2() - .pb_2() - .track_scroll(&self.community_scroll) - .overflow_y_scroll() - .child(self.section_row( - "channels", - "Channels", - channels.len(), - self.channels_open, - |sidebar| sidebar.channels_open = !sidebar.channels_open, - cx, - )) - .when(self.channels_open, |this| { - this.children(channels.into_iter().map(|(id, name, private)| { - self.channel_row(id, name, private, active == Some(id), &community, cx) - })) - }) - .child(self.section_row( - "admins", - "Admins", - admins.len(), - self.admins_open, - |sidebar| sidebar.admins_open = !sidebar.admins_open, - cx, - )) - .when(self.admins_open, |this| { - this.children( - admins - .iter() - .map(|public_key| self.member_row("community-admin", *public_key, cx)), - ) - }) - .child(self.section_row( - "members", - "Members", - members.len(), - self.members_open, - |sidebar| sidebar.members_open = !sidebar.members_open, - cx, - )) - .when(self.members_open, |this| { - this.children( - members - .iter() - .map(|public_key| self.member_row("member", *public_key, cx)), - ) - }); + let rows = Rc::new(rows); + let scroll_handle = &self.community_scroll; v_flex() .flex_1() @@ -539,110 +434,94 @@ impl Sidebar { div().px_2().child( img(banner) .w_full() - .h(px(80.)) - .rounded(cx.theme().radius) + .h_20() + .rounded(cx.theme().radius_lg) .object_fit(ObjectFit::Cover), ), ) }) - .child(sections) - .child(Scrollbar::vertical(&self.community_scroll)) + .child( + div() + .min_h_0() + .flex_1() + .child( + uniform_list( + "community-rows", + rows.len(), + cx.processor(move |this, range, _window, cx| { + this.render_community_rows(range, rows.as_slice(), &community, cx) + }), + ) + .track_scroll(scroll_handle) + .h_full() + .px_2(), + ) + .child(Scrollbar::vertical(scroll_handle)), + ) .into_any_element() } - fn recent_communities(&self, cx: &App) -> Vec> { - const LIMIT: usize = 3; - - let registry = CommunityRegistry::global(cx); - let communities = registry.read(cx).communities(); - let recent = AppSettings::get_recent_communities(cx); - - let mut rows: Vec> = recent - .iter() - .filter_map(|id| { - communities - .iter() - .find(|community| community.read(cx).id().to_hex() == *id) + fn render_community_rows( + &self, + range: Range, + rows: &[CommunityRow], + community: &Entity, + cx: &mut Context, + ) -> Vec { + rows.get(range) + .into_iter() + .flatten() + .map(|row| match row { + CommunityRow::Section(CommunitySection::Channels) => self.section_row( + "channels", + "Channels", + self.channels_open, + |sidebar| sidebar.channels_open = !sidebar.channels_open, + cx, + ), + CommunityRow::Section(CommunitySection::Admins) => self.section_row( + "admins", + "Admins", + self.admins_open, + |sidebar| sidebar.admins_open = !sidebar.admins_open, + cx, + ), + CommunityRow::Section(CommunitySection::Members) => self.section_row( + "members", + "Members", + self.members_open, + |sidebar| sidebar.members_open = !sidebar.members_open, + cx, + ), + CommunityRow::Channel { + id, + name, + private, + selected, + } => self.channel_row(*id, name.clone(), *private, *selected, community, cx), + CommunityRow::Member { prefix, public_key } => { + self.member_row(prefix, *public_key, cx) + } }) - .take(LIMIT) - .cloned() - .collect(); - - if rows.is_empty() { - rows = communities.iter().take(LIMIT).cloned().collect(); - } - - rows + .collect() } fn rows_for(&self, tab: SidebarTab, cx: &App) -> Vec { match tab { - SidebarTab::Recents => { + SidebarTab::Inbox => { let chat = ChatRegistry::global(cx); let rooms = chat.read(cx).rooms(&RoomKind::Ongoing, cx); - let registry = CommunityRegistry::global(cx); - let community_count = registry.read(cx).communities().len(); - let communities = self.recent_communities(cx); - - if communities.is_empty() && rooms.is_empty() { - return vec![SidebarRow::Hint { - text: "Nothing recent yet".into(), - }]; - } - - let mut rows = Vec::new(); - - if !communities.is_empty() { - rows.push(SidebarRow::Section { - label: "Communities".into(), - count: community_count, - }); - rows.extend( - communities - .into_iter() - .map(|community| SidebarRow::Community { community }), - ); - rows.push(SidebarRow::Action { - label: "Show all communities".into(), - tab: SidebarTab::Communities, - }); - } - - if !rooms.is_empty() { - rows.push(SidebarRow::Section { - label: "Chats".into(), - count: rooms.len(), - }); - rows.extend( - rooms - .into_iter() - .take(5) - .map(|room| SidebarRow::Room { room }), - ); - rows.push(SidebarRow::Action { - label: "Show all chats".into(), - tab: SidebarTab::Chats, - }); - } - - rows - } - SidebarTab::Chats => { - let chat = ChatRegistry::global(cx); - let chat = chat.read(cx); - let messages = chat.rooms(&RoomKind::Ongoing, cx); let mut rows = vec![SidebarRow::Section { label: "Chats".into(), - count: messages.len(), }]; - if messages.is_empty() { + if rooms.is_empty() { rows.push(SidebarRow::Hint { text: "No conversations yet".into(), }); } else { - rows.extend(messages.into_iter().map(|room| SidebarRow::Room { room })); + rows.extend(rooms.into_iter().map(|room| SidebarRow::Room { room })); } rows @@ -653,7 +532,6 @@ impl Sidebar { let mut rows = vec![SidebarRow::Section { label: "Communities".into(), - count: communities.len(), }]; if communities.is_empty() { @@ -688,23 +566,19 @@ impl Sidebar { let index = range.start + offset; match row { - SidebarRow::Section { label, count } => TreeRow::new( + SidebarRow::Section { label } => 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| { - this.open_room(room_clone.clone(), window, cx); - }); + let dock = self.dock.clone(); + let room = room.clone(); TreeRow::new( ElementId::NamedInteger("tree-row".into(), index as u64), @@ -714,13 +588,25 @@ impl Sidebar { .avatar(seed) .picture(picture) .created_at(created_at) - .on_click(handler) + // Run in an app context: docking a new panel reads every + // docked panel's id, which would panic inside a sidebar update. + .on_click(move |_event, window, cx| { + ui::dock::add_panel_to( + &dock, + PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)), + DockPlacement::Center, + window, + cx, + ); + }) .into_any_element() } SidebarRow::Community { community } => { let name = community.read(cx).name(); let seed = community.read(cx).id().to_hex(); let picture = community.read(cx).icon(); + let dock = self.dock.clone(); + let sidebar = cx.entity().downgrade(); let community = community.clone(); TreeRow::new( @@ -730,23 +616,22 @@ impl Sidebar { ) .avatar(seed) .picture(picture) - .on_click(cx.listener(move |this, _event, window, cx| { - this.open_community(community.clone(), window, cx); - })) - .into_any_element() - } - SidebarRow::Action { label, tab } => { - let tab = *tab; + .on_click(move |_event, window, cx| { + ui::dock::add_panel_to( + &dock, + PanelHandle::new(community_ui::init(community.clone(), window, cx)), + DockPlacement::Center, + window, + cx, + ); - TreeRow::new( - ElementId::NamedInteger("tree-row".into(), index as u64), - TreeRowKind::Action, - label.clone(), - ) - .icon(IconName::ArrowRight) - .on_click(cx.listener(move |this, _event, _window, cx| { - this.select_tab(tab, cx); - })) + if let Err(error) = sidebar.update(cx, |this, cx| { + this.community = Some(community.downgrade()); + cx.notify(); + }) { + log::error!("Failed to show community in sidebar: {error}"); + } + }) .into_any_element() } SidebarRow::Hint { text } => TreeRow::new( @@ -764,7 +649,6 @@ impl Sidebar { &self, id: &'static str, label: &'static str, - count: usize, open: bool, toggle: impl Fn(&mut Sidebar) + 'static, cx: &mut Context, @@ -778,7 +662,6 @@ impl Sidebar { } else { IconName::CaretRight }) - .count(count) .on_click(cx.listener(move |this, _event, _window, cx| { toggle(this); cx.notify(); @@ -790,7 +673,7 @@ impl Sidebar { fn channel_row( &self, id: ChannelId, - name: String, + name: SharedString, private: bool, selected: bool, community: &Entity, @@ -875,7 +758,11 @@ impl Focusable for Sidebar { impl Render for Sidebar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let nostr = NostrRegistry::global(cx); - let logged_in = { nostr.read(cx).current_user().is_some() }; + let current_user = nostr.read(cx).current_user(); + let logged_in = current_user.is_some(); + + let chat = ChatRegistry::global(cx); + let loading = chat.read(cx).loading(); let community = self .community @@ -890,6 +777,13 @@ impl Render for Sidebar { .bg(cx.theme().surface_background) .border_r_1() .border_color(cx.theme().border_variant) + .when_some(current_user.as_ref(), |this, current_user| { + this.child(title_bar_drag_handlers( + self.render_user(current_user, cx), + window, + cx, + )) + }) .when(!logged_in, |this| { this.relative() .child(title_bar_drag_handlers( @@ -930,11 +824,36 @@ impl Render for Sidebar { ), ) }) - .child(title_bar_drag_handlers(self.render_user(cx), window, cx)) .map(|this| match community { Some(community) => this.child(self.render_community(community, cx)), None => this.child(self.render_tabs(cx)), }) + .when(loading && logged_in, |this| { + this.child( + div() + .absolute() + .bottom_4() + .left_0() + .h_9() + .w_full() + .px_8() + .child( + h_flex() + .gap_2() + .w_full() + .h_9() + .justify_center() + .bg(cx.theme().background.opacity(0.85)) + .when(cx.theme().shadow, |this| this.shadow_md()) + .rounded_full() + .text_xs() + .font_semibold() + .text_color(cx.theme().text_muted) + .child(Indicator::new().small().color(cx.theme().icon_accent)) + .child("Getting messages..."), + ), + ) + }) .into_any_element() } } diff --git a/crates/workspace/src/sidebar/tab.rs b/crates/workspace/src/sidebar/tab.rs index eda0d77b..1fccd595 100644 --- a/crates/workspace/src/sidebar/tab.rs +++ b/crates/workspace/src/sidebar/tab.rs @@ -1,121 +1,30 @@ -use std::rc::Rc; - -use gpui::prelude::FluentBuilder; -use gpui::{App, InteractiveElement, 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, + Inbox, Communities, } impl SidebarTab { - pub const ALL: [SidebarTab; 3] = [Self::Recents, Self::Chats, Self::Communities]; + pub const ALL: [SidebarTab; 2] = [Self::Inbox, Self::Communities]; pub fn label(self) -> &'static str { match self { - Self::Recents => "Recents", - Self::Chats => "Chats", + Self::Inbox => "Inbox", 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::Inbox => "sidebar-inbox", Self::Communities => "sidebar-communities", } } pub fn index(self) -> usize { match self { - Self::Recents => 0, - Self::Chats => 1, - Self::Communities => 2, + Self::Inbox => 0, + Self::Communities => 1, } } - - pub fn chat(self) -> bool { - matches!(self, Self::Chats) - } - - pub fn community(self) -> bool { - matches!(self, Self::Communities) - } -} - -#[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() - .id("sidebar-tabs") - .absolute() - .bottom_3() - .left_0() - .w_full() - .px_4() - .child( - h_flex() - .w_full() - .p_1() - .gap_1() - .rounded_full() - .bg(cx.theme().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() - .rounded() - .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 20323851..0910b4d3 100644 --- a/crates/workspace/src/sidebar/tree.rs +++ b/crates/workspace/src/sidebar/tree.rs @@ -1,36 +1,45 @@ use std::rc::Rc; use chat::Room; -use community::Community; +use community::{ChannelId, Community}; use gpui::prelude::FluentBuilder; use gpui::{ App, ClickEvent, ElementId, Entity, ImageSource, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, px, }; +use nostr_sdk::prelude::PublicKey; use settings::AppSettings; use theme::ActiveTheme; use ui::avatar::{Avatar, PixelAvatar}; use ui::{Icon, IconName, Selectable, Sizable, StyledExt, h_flex}; -use super::tab::SidebarTab; - pub enum SidebarRow { - Section { - label: SharedString, - count: usize, + Section { label: SharedString }, + Room { room: Entity }, + Community { community: Entity }, + Hint { text: SharedString }, +} + +/// A collapsible group of rows in the sidebar's community view. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum CommunitySection { + Channels, + Admins, + Members, +} + +/// A row in the sidebar's community view. +pub enum CommunityRow { + Section(CommunitySection), + Channel { + id: ChannelId, + name: SharedString, + private: bool, + selected: bool, }, - Room { - room: Entity, - }, - Community { - community: Entity, - }, - Action { - label: SharedString, - tab: SidebarTab, - }, - Hint { - text: SharedString, + Member { + prefix: &'static str, + public_key: PublicKey, }, } @@ -39,7 +48,6 @@ pub enum TreeRowKind { Section, Room, Community, - Action, Hint, } @@ -51,7 +59,6 @@ pub struct TreeRow { avatar: Option, picture: Option, icon: Option, - count: Option, created_at: Option, selected: bool, #[allow(clippy::type_complexity)] @@ -71,7 +78,6 @@ impl TreeRow { avatar: None, picture: None, icon: None, - count: None, created_at: None, selected: false, on_click: None, @@ -96,11 +102,6 @@ impl TreeRow { self } - pub fn count(mut self, count: usize) -> Self { - self.count = Some(count); - self - } - pub fn created_at(mut self, created_at: impl Into) -> Self { self.created_at = Some(created_at.into()); self @@ -133,7 +134,6 @@ impl RenderOnce for TreeRow { let is_section = self.kind == TreeRowKind::Section; let is_room = self.kind == TreeRowKind::Room; let is_community = self.kind == TreeRowKind::Community; - let is_action = self.kind == TreeRowKind::Action; let is_hint = self.kind == TreeRowKind::Hint; let is_selected = self.selected; @@ -172,7 +172,6 @@ impl RenderOnce for TreeRow { h_flex() .id(self.id) - .h_8() .w_full() .px_2() .gap_2() @@ -182,10 +181,8 @@ impl RenderOnce for TreeRow { .text_color(cx.theme().text_placeholder) .font_semibold() }) - .when(is_room || is_community, |this| this.text_sm()) - .when(is_action, |this| { - this.text_sm().text_color(cx.theme().text_muted) - }) + .when(is_room || is_community, |this| this.text_sm().h_10()) + .h_8() .when(is_hint, |this| { this.text_xs() .font_normal() @@ -203,6 +200,7 @@ impl RenderOnce for TreeRow { .when(is_room, |this| this.font_medium()) .child(self.label), ) + .child(div().flex_1()) .when(is_selected, |this| { this.child( Icon::new(IconName::CheckCircle) @@ -211,11 +209,8 @@ impl RenderOnce for TreeRow { .text_color(cx.theme().icon_accent), ) }) - .when_some(self.count, |this, count| { - this.child(div().flex_shrink_0().font_normal().child(count.to_string())) - }) .when_some(self.created_at, |this, created_at| { - this.child(div().flex_1()).child( + this.child( div() .flex_shrink_0() .text_color(cx.theme().text_placeholder)