update sidebar

This commit is contained in:
2026-09-23 20:19:42 +07:00
parent 273ddabde5
commit c61be4a139
6 changed files with 485 additions and 596 deletions
+5
View File
@@ -129,6 +129,11 @@ impl AutoUpdater {
matches!(self.status, UpdateStatus::Idle) 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. /// Whether a verified update is installed and waiting for a restart.
pub fn staged(&self) -> bool { pub fn staged(&self) -> bool {
matches!(self.status, UpdateStatus::Staged(_)) matches!(self.status, UpdateStatus::Staged(_))
+76 -31
View File
@@ -25,6 +25,7 @@ pub struct Tab {
children: Vec<AnyElement>, children: Vec<AnyElement>,
pub(super) disabled: bool, pub(super) disabled: bool,
pub(super) selected: bool, pub(super) selected: bool,
pub(super) segmented: bool,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>, on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
} }
@@ -69,6 +70,7 @@ impl Default for Tab {
children: Vec::new(), children: Vec::new(),
disabled: false, disabled: false,
selected: false, selected: false,
segmented: false,
prefix: None, prefix: None,
suffix: None, suffix: None,
on_click: None, on_click: None,
@@ -132,6 +134,12 @@ impl Tab {
self.tab_bar_prefix = Some(tab_bar_prefix); self.tab_bar_prefix = Some(tab_bar_prefix);
self 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 { impl ParentElement for Tab {
@@ -167,74 +175,111 @@ impl Styled for Tab {
impl RenderOnce for Tab { impl RenderOnce for Tab {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { 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 cx.theme().text_muted
} else if self.selected { } else if selected {
cx.theme().tab_active_foreground cx.theme().tab_active_foreground
} else { } else {
cx.theme().tab_foreground cx.theme().tab_foreground
}; };
self.base let content = h_flex()
.id(self.ix)
.flex()
.flex_wrap()
.gap_1()
.items_center()
.flex_shrink_0()
.h(TABBAR_HEIGHT)
.relative()
.overflow_hidden()
.text_color(fg)
.text_sm()
.when_some(self.prefix, |this, prefix| this.child(prefix))
.child(
h_flex()
.flex_1() .flex_1()
.h(px(30.)) .map(|this| {
if segmented {
this.h(px(24.))
} else {
this.h(px(30.))
}
})
.line_height(relative(1.)) .line_height(relative(1.))
.whitespace_nowrap() .whitespace_nowrap()
.items_center() .items_center()
.justify_center() .justify_center()
.overflow_hidden() .overflow_hidden()
.flex_shrink_0() .when(segmented, |this| this.px_1())
.px_3() .when(!segmented, |this| this.flex_shrink_0().px_3())
.map(|this| match self.icon { .map(|this| match icon {
Some(icon) => this.w(px(38.)).child(icon.size_4()), Some(icon) => this.w(px(38.)).child(icon.size_4()),
None => this None => this
.map(|this| match self.label { .map(|this| match label {
Some(label) => this.child(label), Some(label) => this.child(label),
None => this, None => this,
}) })
.children(self.children), .children(children),
}), });
)
.when_some(self.suffix, |this, suffix| { base.id(ix)
.flex()
.items_center()
.text_color(fg)
.when(segmented, |this| {
this.text_xs()
.flex_1()
.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()
.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)) this.child(div().pr_2().child(suffix))
}) })
.on_mouse_down(MouseButton::Left, |_ev, _window, cx| { .on_mouse_down(MouseButton::Left, |_ev, _window, cx| {
cx.stop_propagation(); cx.stop_propagation();
}) })
.when(!self.disabled, |this| { .when(!disabled, |this| {
this.when_some(self.on_click.clone(), |this, on_click| { this.when_some(on_click, |this, on_click| {
this.on_click(move |event, window, cx| on_click(event, window, cx)) this.on_click(move |event, window, cx| on_click(event, window, cx))
}) })
}) })
.child( .when(!segmented, |this| {
this.child(
div() div()
.absolute() .absolute()
.bottom_0() .bottom_0()
.left_0() .left_0()
.right_0() .right_0()
.h_0p5() .h_0p5()
.when(self.selected && !self.disabled, |this| { .when(selected && !disabled, |this| {
this.bg(cx.theme().element_active) this.bg(cx.theme().element_active)
}) })
.when(!self.selected && !self.disabled, |this| { .when(!selected && !disabled, |this| {
this.invisible().group_hover("", |this| { this.invisible().group_hover("", |this| {
this.visible().bg(cx.theme().secondary_background) this.visible().bg(cx.theme().secondary_background)
}) })
}), }),
) )
})
} }
} }
+19 -3
View File
@@ -7,6 +7,7 @@ use gpui::{
Window, div, px, Window, div, px,
}; };
use smallvec::SmallVec; use smallvec::SmallVec;
use theme::ActiveTheme;
use super::Tab; use super::Tab;
use crate::button::{Button, ButtonVariants as _}; use crate::button::{Button, ButtonVariants as _};
@@ -25,6 +26,7 @@ pub struct TabBar {
last_empty_space: AnyElement, last_empty_space: AnyElement,
selected_index: Option<usize>, selected_index: Option<usize>,
menu: bool, menu: bool,
segmented: bool,
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
on_click: Option<Rc<dyn Fn(&usize, &mut Window, &mut App) + 'static>>, on_click: Option<Rc<dyn Fn(&usize, &mut Window, &mut App) + 'static>>,
} }
@@ -43,9 +45,16 @@ impl TabBar {
selected_index: None, selected_index: None,
on_click: None, on_click: None,
menu: false, 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. /// Set whether to show the menu button when tabs overflow, default is false.
pub fn menu(mut self, menu: bool) -> Self { pub fn menu(mut self, menu: bool) -> Self {
self.menu = menu; self.menu = menu;
@@ -113,10 +122,11 @@ impl Styled for TabBar {
} }
impl RenderOnce 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 mut item_labels = Vec::new();
let selected_index = self.selected_index; let selected_index = self.selected_index;
let on_click = self.on_click.clone(); let on_click = self.on_click.clone();
let segmented = self.segmented;
self.base self.base
.group("tab-bar") .group("tab-bar")
@@ -124,22 +134,28 @@ impl RenderOnce for TabBar {
.flex() .flex()
.items_center() .items_center()
.refine_style(&self.style) .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)) .when_some(self.prefix, |this, prefix| this.child(prefix))
.child( .child(
h_flex() h_flex()
.id("tabs") .id("tabs")
.flex_1() .flex_1()
.overflow_x_scroll() .when(!segmented, |this| this.overflow_x_scroll())
.when_some(self.scroll_handle, |this, scroll_handle| { .when_some(self.scroll_handle, |this, scroll_handle| {
this.track_scroll(&scroll_handle) this.track_scroll(&scroll_handle)
}) })
.gap(px(0.)) .gap_1()
.children(self.children.into_iter().enumerate().map(|(ix, child)| { .children(self.children.into_iter().enumerate().map(|(ix, child)| {
item_labels.push((child.label.clone(), child.disabled)); item_labels.push((child.label.clone(), child.disabled));
let tab_bar_prefix = child.tab_bar_prefix.unwrap_or(true); let tab_bar_prefix = child.tab_bar_prefix.unwrap_or(true);
child child
.ix(ix) .ix(ix)
.tab_bar_prefix(tab_bar_prefix) .tab_bar_prefix(tab_bar_prefix)
.segmented(segmented)
.when_some(self.selected_index, |this, selected_ix| { .when_some(self.selected_index, |this, selected_ix| {
this.selected(selected_ix == ix) this.selected(selected_ix == ix)
}) })
+254 -335
View File
@@ -2,19 +2,18 @@ use std::ops::Range;
use std::rc::Rc; use std::rc::Rc;
use auto_update::AutoUpdater; use auto_update::AutoUpdater;
use chat::{ChatEvent, ChatRegistry, Room, RoomKind}; use chat::{ChatEvent, ChatRegistry, RoomKind};
use common::TimestampExt; use common::TimestampExt;
use community::{ChannelId, Community, CommunityEvent, CommunityRegistry}; use community::{ChannelId, Community, CommunityEvent, CommunityRegistry};
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
AnyElement, App, Context, Div, ElementId, Entity, EventEmitter, FocusHandle, Focusable, AnyElement, App, Context, Div, ElementId, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, ScrollHandle, SharedString, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString, Stateful,
Stateful, StatefulInteractiveElement, Styled, StyledImage, Subscription, Styled, StyledImage, Subscription, UniformListScrollHandle, WeakEntity, Window, div, img, px,
UniformListScrollHandle, WeakEntity, Window, div, img, px, retain_all, uniform_list, retain_all, uniform_list,
}; };
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::PersonRegistry; use person::PersonRegistry;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::NostrRegistry; use state::NostrRegistry;
use theme::{ActiveTheme, TABBAR_HEIGHT}; use theme::{ActiveTheme, TABBAR_HEIGHT};
@@ -26,9 +25,11 @@ use ui::menu::{DropdownMenu, PopupMenuItem};
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::{
Icon, IconName, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, WindowExtension, h_flex, Disableable, Icon, IconName, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, WindowExtension,
title_bar_drag_handlers, v_flex, h_flex, title_bar_drag_handlers, v_flex,
}; };
use crate::Command; use crate::Command;
@@ -37,15 +38,15 @@ use crate::dialogs::import;
mod tab; mod tab;
mod tree; mod tree;
use tab::{SidebarTab, TabBar}; use tab::SidebarTab;
use tree::SidebarRow; use tree::{CommunityRow, CommunitySection, SidebarRow};
pub(crate) use tree::{TreeRow, TreeRowKind}; pub(crate) use tree::{TreeRow, TreeRowKind};
pub struct Sidebar { pub struct Sidebar {
focus_handle: FocusHandle, focus_handle: FocusHandle,
scroll_handles: [UniformListScrollHandle; 3], scroll_handles: [UniformListScrollHandle; 2],
/// Scroll state of the channel and member lists /// Scroll state of the community's channel and member lists
community_scroll: ScrollHandle, community_scroll: UniformListScrollHandle,
/// The dock the sidebar opens its panels in /// The dock the sidebar opens its panels in
dock: WeakEntity<DockArea>, dock: WeakEntity<DockArea>,
active_tab: SidebarTab, active_tab: SidebarTab,
@@ -89,11 +90,10 @@ impl Sidebar {
scroll_handles: [ scroll_handles: [
UniformListScrollHandle::new(), UniformListScrollHandle::new(),
UniformListScrollHandle::new(), UniformListScrollHandle::new(),
UniformListScrollHandle::new(),
], ],
community_scroll: ScrollHandle::default(), community_scroll: UniformListScrollHandle::new(),
dock, dock,
active_tab: SidebarTab::Recents, active_tab: SidebarTab::Inbox,
community: None, community: None,
channels_open: true, channels_open: true,
admins_open: true, admins_open: true,
@@ -111,42 +111,6 @@ impl Sidebar {
cx.notify(); cx.notify();
} }
fn open_community(
&mut self,
community: Entity<Community>,
window: &mut Window,
cx: &mut Context<Self>,
) {
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<Room>, window: &mut Window, cx: &mut Context<Self>) {
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. /// Leave the community view, returning the sidebar to its tab list.
fn reset_community(&mut self, cx: &mut Context<Self>) { fn reset_community(&mut self, cx: &mut Context<Self>) {
if self.community.take().is_none() { if self.community.take().is_none() {
@@ -155,9 +119,12 @@ impl Sidebar {
cx.notify(); cx.notify();
} }
fn render_user(&self, cx: &mut Context<Self>) -> Stateful<Div> { fn render_user(&self, current_user: &PublicKey, cx: &mut Context<Self>) -> Stateful<Div> {
let nostr = NostrRegistry::global(cx); let persons = PersonRegistry::global(cx);
let current_user = nostr.read(cx).current_user(); 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() h_flex()
.id("sidebar-user") .id("sidebar-user")
@@ -170,14 +137,7 @@ impl Sidebar {
.when(cfg!(target_os = "macos"), |this| { .when(cfg!(target_os = "macos"), |this| {
this.pl(px(TRAFFIC_LIGHT_PADDING)) this.pl(px(TRAFFIC_LIGHT_PADDING))
}) })
.when_some(current_user.as_ref(), |this, public_key| { .child(
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();
this.child(
Button::new("current-user") Button::new("current-user")
.child( .child(
Avatar::new(avatar.clone()) Avatar::new(avatar.clone())
@@ -188,7 +148,7 @@ impl Sidebar {
.caret() .caret()
.compact() .compact()
.transparent() .transparent()
.dropdown_menu(move |this, _window, cx| { .dropdown_menu(move |this, _window, _cx| {
let avatar = avatar.clone(); let avatar = avatar.clone();
let avatar_seed = avatar_seed.clone(); let avatar_seed = avatar_seed.clone();
let name = name.clone(); let name = name.clone();
@@ -207,11 +167,7 @@ impl Sidebar {
.child(name.clone()) .child(name.clone())
})) }))
.separator() .separator()
.menu_with_icon( .menu_with_icon("Inbox", IconName::Inbox, Box::new(Command::ShowInbox))
"Inbox",
IconName::Inbox,
Box::new(Command::ShowInbox),
)
.menu_with_icon( .menu_with_icon(
"Search", "Search",
IconName::Search, IconName::Search,
@@ -232,18 +188,8 @@ impl Sidebar {
IconName::UserKey, IconName::UserKey,
Box::new(Command::ShowBackup), Box::new(Command::ShowBackup),
) )
.menu_with_icon( .menu_with_icon("Themes", IconName::Sun, Box::new(Command::ToggleTheme))
"Themes", .separator()
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),
)
})
.menu_with_icon( .menu_with_icon(
"Settings", "Settings",
IconName::Settings, IconName::Settings,
@@ -251,7 +197,6 @@ impl Sidebar {
) )
}), }),
) )
})
.child(div().flex_1()) .child(div().flex_1())
.when_some(AutoUpdater::try_global(cx), |this, updater| { .when_some(AutoUpdater::try_global(cx), |this, updater| {
this.child(self.render_updater(updater, cx)) this.child(self.render_updater(updater, cx))
@@ -272,12 +217,21 @@ impl Sidebar {
fn render_updater(&self, updater: Entity<AutoUpdater>, cx: &mut App) -> AnyElement { fn render_updater(&self, updater: Entity<AutoUpdater>, cx: &mut App) -> AnyElement {
let status = updater.read(cx).status(); let status = updater.read(cx).status();
let up_to_date = updater.read(cx).up_to_date();
let staged = updater.read(cx).staged(); let staged = updater.read(cx).staged();
h_flex() h_flex()
.gap_2() .gap_2()
.text_xs() .when(!up_to_date, |this| {
.child(status) this.child(
Button::new("update-status")
.icon(IconName::ArrowDownCircle)
.tooltip(status)
.small()
.warning()
.disabled(true),
)
})
.when(staged, |this| { .when(staged, |this| {
this.child( this.child(
Button::new("restart-to-update") Button::new("restart-to-update")
@@ -296,9 +250,6 @@ impl Sidebar {
} }
fn render_tabs(&mut self, cx: &mut Context<Self>) -> AnyElement { fn render_tabs(&mut self, cx: &mut Context<Self>) -> AnyElement {
let chat = ChatRegistry::global(cx);
let loading = chat.read(cx).loading();
let sidebar = cx.entity().downgrade(); let sidebar = cx.entity().downgrade();
let active_tab = self.active_tab; let active_tab = self.active_tab;
let rows = Rc::new(self.rows_for(active_tab, cx)); let rows = Rc::new(self.rows_for(active_tab, cx));
@@ -308,28 +259,46 @@ impl Sidebar {
.size_full() .size_full()
.flex_1() .flex_1()
.min_h_0() .min_h_0()
.gap_1() .gap_2()
.when(active_tab.chat(), |this| { .child(
this.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() v_flex()
.px_2() .px_2()
.gap_1() .gap_1()
.child( .child(
NavItem::new( NavItem::new(
"nav-contacts", "new-chat",
"Contacts", "New Chat",
Icon::new(IconName::Book).small(), Icon::new(IconName::Message).small(),
) )
.on_click(|_event, window, cx| { .on_click(|_event, window, cx| {
window.dispatch_action(Box::new(Command::ShowContactList), cx) window.dispatch_action(Box::new(Command::NewChat), cx)
}), }),
) )
.child( .child(
NavItem::new( NavItem::new("reqs", "Requests", Icon::new(IconName::Invite).small())
"nav-requests",
"Requests",
Icon::new(IconName::Invite).small(),
)
.when(self.new_requests, |this| { .when(self.new_requests, |this| {
this.suffix(div().size_1().rounded_full().bg(cx.theme().cursor)) this.suffix(div().size_1().rounded_full().bg(cx.theme().cursor))
}) })
@@ -347,51 +316,38 @@ impl Sidebar {
}), }),
) )
.child( .child(
NavItem::new( NavItem::new("contacts", "Contacts", Icon::new(IconName::Book).small())
"nav-new-chat",
"New chat",
Icon::new(IconName::Plus).small(),
)
.on_click(|_event, window, cx| { .on_click(|_event, window, cx| {
window.dispatch_action(Box::new(Command::NewChat), cx) window.dispatch_action(Box::new(Command::ShowContactList), cx)
}), }),
), ),
) ),
}) SidebarTab::Communities => this.child(
.when(active_tab.community(), |this| {
this.child(
v_flex() v_flex()
.px_2() .px_2()
.gap_1() .gap_1()
.child( .child(
NavItem::new( NavItem::new(
"nav-browse", "new-community",
"Browse", "New Community",
Icon::new(IconName::Compass).small(), Icon::new(IconName::Group).small(),
) )
.on_click(|_event, window, cx| { .on_click(|_, window, cx| {
window.dispatch_action(Box::new(Command::ShowBrowse), 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) window.dispatch_action(Box::new(Command::NewCommunity), cx)
}), }),
),
) )
.child(
NavItem::new("browse", "Browse", Icon::new(IconName::Compass).small())
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(Command::ShowBrowse), cx)
}),
),
),
}) })
.child( .child(
v_flex() div()
.size_full()
.flex_1()
.min_h_0() .min_h_0()
.gap_1() .flex_1()
.pb_12()
.child( .child(
uniform_list( uniform_list(
active_tab.list_id(), active_tab.list_id(),
@@ -401,46 +357,11 @@ impl Sidebar {
}), }),
) )
.track_scroll(scroll_handle) .track_scroll(scroll_handle)
.flex_1()
.h_full() .h_full()
.px_2(), .px_2(),
) )
.child(Scrollbar::vertical(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_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() .into_any_element()
} }
@@ -449,7 +370,7 @@ impl Sidebar {
community: Entity<Community>, community: Entity<Community>,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> AnyElement { ) -> AnyElement {
let (channels, active, admins, members, banner) = { let (banner, rows) = {
let community = community.read(cx); let community = community.read(cx);
let owner = community.state().owner; let owner = community.state().owner;
let mut admins = Vec::new(); let mut admins = Vec::new();
@@ -463,71 +384,45 @@ impl Sidebar {
} }
} }
( let active = community.active_channel();
let banner = community.banner();
let mut rows = vec![CommunityRow::Section(CommunitySection::Channels)];
if self.channels_open {
rows.extend(
community community
.channels() .channels()
.iter() .iter()
.map(|channel| (channel.id, channel.name.clone(), channel.private)) .map(|channel| CommunityRow::Channel {
.collect::<Vec<_>>(), id: channel.id,
community.active_channel(), name: channel.name.clone().into(),
admins, private: channel.private,
members, selected: active == Some(channel.id),
community.banner(), }),
) );
}
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() let rows = Rc::new(rows);
.id("community-sections") let scroll_handle = &self.community_scroll;
.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)),
)
});
v_flex() v_flex()
.flex_1() .flex_1()
@@ -539,110 +434,94 @@ impl Sidebar {
div().px_2().child( div().px_2().child(
img(banner) img(banner)
.w_full() .w_full()
.h(px(80.)) .h_20()
.rounded(cx.theme().radius) .rounded(cx.theme().radius_lg)
.object_fit(ObjectFit::Cover), .object_fit(ObjectFit::Cover),
), ),
) )
}) })
.child(sections) .child(
.child(Scrollbar::vertical(&self.community_scroll)) 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() .into_any_element()
} }
fn recent_communities(&self, cx: &App) -> Vec<Entity<Community>> { fn render_community_rows(
const LIMIT: usize = 3; &self,
range: Range<usize>,
let registry = CommunityRegistry::global(cx); rows: &[CommunityRow],
let communities = registry.read(cx).communities(); community: &Entity<Community>,
let recent = AppSettings::get_recent_communities(cx); cx: &mut Context<Self>,
) -> Vec<AnyElement> {
let mut rows: Vec<Entity<Community>> = recent rows.get(range)
.iter() .into_iter()
.filter_map(|id| { .flatten()
communities .map(|row| match row {
.iter() CommunityRow::Section(CommunitySection::Channels) => self.section_row(
.find(|community| community.read(cx).id().to_hex() == *id) "channels",
}) "Channels",
.take(LIMIT) self.channels_open,
.cloned() |sidebar| sidebar.channels_open = !sidebar.channels_open,
.collect(); cx,
),
if rows.is_empty() { CommunityRow::Section(CommunitySection::Admins) => self.section_row(
rows = communities.iter().take(LIMIT).cloned().collect(); "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)
} }
})
rows .collect()
} }
fn rows_for(&self, tab: SidebarTab, cx: &App) -> Vec<SidebarRow> { fn rows_for(&self, tab: SidebarTab, cx: &App) -> Vec<SidebarRow> {
match tab { match tab {
SidebarTab::Recents => { SidebarTab::Inbox => {
let chat = ChatRegistry::global(cx); let chat = ChatRegistry::global(cx);
let rooms = chat.read(cx).rooms(&RoomKind::Ongoing, 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 { let mut rows = vec![SidebarRow::Section {
label: "Chats".into(), label: "Chats".into(),
count: messages.len(),
}]; }];
if messages.is_empty() { if rooms.is_empty() {
rows.push(SidebarRow::Hint { rows.push(SidebarRow::Hint {
text: "No conversations yet".into(), text: "No conversations yet".into(),
}); });
} else { } else {
rows.extend(messages.into_iter().map(|room| SidebarRow::Room { room })); rows.extend(rooms.into_iter().map(|room| SidebarRow::Room { room }));
} }
rows rows
@@ -653,7 +532,6 @@ impl Sidebar {
let mut rows = vec![SidebarRow::Section { let mut rows = vec![SidebarRow::Section {
label: "Communities".into(), label: "Communities".into(),
count: communities.len(),
}]; }];
if communities.is_empty() { if communities.is_empty() {
@@ -688,23 +566,19 @@ impl Sidebar {
let index = range.start + offset; let index = range.start + offset;
match row { match row {
SidebarRow::Section { label, count } => TreeRow::new( SidebarRow::Section { label } => TreeRow::new(
ElementId::NamedInteger("tree-row".into(), index as u64), ElementId::NamedInteger("tree-row".into(), index as u64),
TreeRowKind::Section, TreeRowKind::Section,
label.clone(), label.clone(),
) )
.count(*count)
.into_any_element(), .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);
let seed = room.read(cx).display_image_seed(cx); let seed = room.read(cx).display_image_seed(cx);
let created_at = room.read(cx).created_at.to_ago(); let created_at = room.read(cx).created_at.to_ago();
let room_clone = room.clone(); let dock = self.dock.clone();
let room = room.clone();
let handler = cx.listener(move |this, _event, window, cx| {
this.open_room(room_clone.clone(), window, cx);
});
TreeRow::new( TreeRow::new(
ElementId::NamedInteger("tree-row".into(), index as u64), ElementId::NamedInteger("tree-row".into(), index as u64),
@@ -714,13 +588,25 @@ impl Sidebar {
.avatar(seed) .avatar(seed)
.picture(picture) .picture(picture)
.created_at(created_at) .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() .into_any_element()
} }
SidebarRow::Community { community } => { SidebarRow::Community { community } => {
let name = community.read(cx).name(); let name = community.read(cx).name();
let seed = community.read(cx).id().to_hex(); let seed = community.read(cx).id().to_hex();
let picture = community.read(cx).icon(); let picture = community.read(cx).icon();
let dock = self.dock.clone();
let sidebar = cx.entity().downgrade();
let community = community.clone(); let community = community.clone();
TreeRow::new( TreeRow::new(
@@ -730,23 +616,22 @@ impl Sidebar {
) )
.avatar(seed) .avatar(seed)
.picture(picture) .picture(picture)
.on_click(cx.listener(move |this, _event, window, cx| { .on_click(move |_event, window, cx| {
this.open_community(community.clone(), window, cx); ui::dock::add_panel_to(
})) &dock,
.into_any_element() PanelHandle::new(community_ui::init(community.clone(), window, cx)),
} DockPlacement::Center,
SidebarRow::Action { label, tab } => { window,
let tab = *tab; cx,
);
TreeRow::new( if let Err(error) = sidebar.update(cx, |this, cx| {
ElementId::NamedInteger("tree-row".into(), index as u64), this.community = Some(community.downgrade());
TreeRowKind::Action, cx.notify();
label.clone(), }) {
) log::error!("Failed to show community in sidebar: {error}");
.icon(IconName::ArrowRight) }
.on_click(cx.listener(move |this, _event, _window, cx| { })
this.select_tab(tab, cx);
}))
.into_any_element() .into_any_element()
} }
SidebarRow::Hint { text } => TreeRow::new( SidebarRow::Hint { text } => TreeRow::new(
@@ -764,7 +649,6 @@ impl Sidebar {
&self, &self,
id: &'static str, id: &'static str,
label: &'static str, label: &'static str,
count: usize,
open: bool, open: bool,
toggle: impl Fn(&mut Sidebar) + 'static, toggle: impl Fn(&mut Sidebar) + 'static,
cx: &mut Context<Sidebar>, cx: &mut Context<Sidebar>,
@@ -778,7 +662,6 @@ impl Sidebar {
} else { } else {
IconName::CaretRight IconName::CaretRight
}) })
.count(count)
.on_click(cx.listener(move |this, _event, _window, cx| { .on_click(cx.listener(move |this, _event, _window, cx| {
toggle(this); toggle(this);
cx.notify(); cx.notify();
@@ -790,7 +673,7 @@ impl Sidebar {
fn channel_row( fn channel_row(
&self, &self,
id: ChannelId, id: ChannelId,
name: String, name: SharedString,
private: bool, private: bool,
selected: bool, selected: bool,
community: &Entity<Community>, community: &Entity<Community>,
@@ -875,7 +758,11 @@ impl Focusable for Sidebar {
impl Render for Sidebar { impl Render for Sidebar {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let nostr = NostrRegistry::global(cx); 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 let community = self
.community .community
@@ -890,6 +777,13 @@ impl Render for Sidebar {
.bg(cx.theme().surface_background) .bg(cx.theme().surface_background)
.border_r_1() .border_r_1()
.border_color(cx.theme().border_variant) .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| { .when(!logged_in, |this| {
this.relative() this.relative()
.child(title_bar_drag_handlers( .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 { .map(|this| match community {
Some(community) => this.child(self.render_community(community, cx)), Some(community) => this.child(self.render_community(community, cx)),
None => this.child(self.render_tabs(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() .into_any_element()
} }
} }
+6 -97
View File
@@ -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)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SidebarTab { pub enum SidebarTab {
Recents, Inbox,
Chats,
Communities, Communities,
} }
impl SidebarTab { 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 { pub fn label(self) -> &'static str {
match self { match self {
Self::Recents => "Recents", Self::Inbox => "Inbox",
Self::Chats => "Chats",
Self::Communities => "Communities", 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 { pub fn list_id(self) -> &'static str {
match self { match self {
Self::Recents => "sidebar-recents", Self::Inbox => "sidebar-inbox",
Self::Chats => "sidebar-chats",
Self::Communities => "sidebar-communities", Self::Communities => "sidebar-communities",
} }
} }
pub fn index(self) -> usize { pub fn index(self) -> usize {
match self { match self {
Self::Recents => 0, Self::Inbox => 0,
Self::Chats => 1, Self::Communities => 1,
Self::Communities => 2,
} }
} }
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<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()
.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);
}
})
})),
)
}
} }
+31 -36
View File
@@ -1,36 +1,45 @@
use std::rc::Rc; use std::rc::Rc;
use chat::Room; use chat::Room;
use community::Community; use community::{ChannelId, Community};
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
App, ClickEvent, ElementId, Entity, ImageSource, InteractiveElement, IntoElement, App, ClickEvent, ElementId, Entity, ImageSource, InteractiveElement, IntoElement,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, px, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, px,
}; };
use nostr_sdk::prelude::PublicKey;
use settings::AppSettings; use settings::AppSettings;
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::avatar::{Avatar, PixelAvatar}; use ui::avatar::{Avatar, PixelAvatar};
use ui::{Icon, IconName, Selectable, Sizable, StyledExt, h_flex}; use ui::{Icon, IconName, Selectable, Sizable, StyledExt, h_flex};
use super::tab::SidebarTab;
pub enum SidebarRow { pub enum SidebarRow {
Section { Section { label: SharedString },
label: SharedString, Room { room: Entity<Room> },
count: usize, Community { community: Entity<Community> },
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 { Member {
room: Entity<Room>, prefix: &'static str,
}, public_key: PublicKey,
Community {
community: Entity<Community>,
},
Action {
label: SharedString,
tab: SidebarTab,
},
Hint {
text: SharedString,
}, },
} }
@@ -39,7 +48,6 @@ pub enum TreeRowKind {
Section, Section,
Room, Room,
Community, Community,
Action,
Hint, Hint,
} }
@@ -51,7 +59,6 @@ pub struct TreeRow {
avatar: Option<SharedString>, avatar: Option<SharedString>,
picture: Option<ImageSource>, picture: Option<ImageSource>,
icon: Option<IconName>, icon: Option<IconName>,
count: Option<usize>,
created_at: Option<SharedString>, created_at: Option<SharedString>,
selected: bool, selected: bool,
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
@@ -71,7 +78,6 @@ impl TreeRow {
avatar: None, avatar: None,
picture: None, picture: None,
icon: None, icon: None,
count: None,
created_at: None, created_at: None,
selected: false, selected: false,
on_click: None, on_click: None,
@@ -96,11 +102,6 @@ impl TreeRow {
self self
} }
pub fn count(mut self, count: usize) -> Self {
self.count = Some(count);
self
}
pub fn created_at(mut self, created_at: impl Into<SharedString>) -> Self { pub fn created_at(mut self, created_at: impl Into<SharedString>) -> Self {
self.created_at = Some(created_at.into()); self.created_at = Some(created_at.into());
self self
@@ -133,7 +134,6 @@ impl RenderOnce for TreeRow {
let is_section = self.kind == TreeRowKind::Section; let is_section = self.kind == TreeRowKind::Section;
let is_room = self.kind == TreeRowKind::Room; let is_room = self.kind == TreeRowKind::Room;
let is_community = self.kind == TreeRowKind::Community; let is_community = self.kind == TreeRowKind::Community;
let is_action = self.kind == TreeRowKind::Action;
let is_hint = self.kind == TreeRowKind::Hint; let is_hint = self.kind == TreeRowKind::Hint;
let is_selected = self.selected; let is_selected = self.selected;
@@ -172,7 +172,6 @@ impl RenderOnce for TreeRow {
h_flex() h_flex()
.id(self.id) .id(self.id)
.h_8()
.w_full() .w_full()
.px_2() .px_2()
.gap_2() .gap_2()
@@ -182,10 +181,8 @@ impl RenderOnce for TreeRow {
.text_color(cx.theme().text_placeholder) .text_color(cx.theme().text_placeholder)
.font_semibold() .font_semibold()
}) })
.when(is_room || is_community, |this| this.text_sm()) .when(is_room || is_community, |this| this.text_sm().h_10())
.when(is_action, |this| { .h_8()
this.text_sm().text_color(cx.theme().text_muted)
})
.when(is_hint, |this| { .when(is_hint, |this| {
this.text_xs() this.text_xs()
.font_normal() .font_normal()
@@ -203,6 +200,7 @@ impl RenderOnce for TreeRow {
.when(is_room, |this| this.font_medium()) .when(is_room, |this| this.font_medium())
.child(self.label), .child(self.label),
) )
.child(div().flex_1())
.when(is_selected, |this| { .when(is_selected, |this| {
this.child( this.child(
Icon::new(IconName::CheckCircle) Icon::new(IconName::CheckCircle)
@@ -211,11 +209,8 @@ impl RenderOnce for TreeRow {
.text_color(cx.theme().icon_accent), .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| { .when_some(self.created_at, |this, created_at| {
this.child(div().flex_1()).child( this.child(
div() div()
.flex_shrink_0() .flex_shrink_0()
.text_color(cx.theme().text_placeholder) .text_color(cx.theme().text_placeholder)