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
+6 -1
View File
@@ -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(_))
+94 -49
View File
@@ -25,6 +25,7 @@ pub struct Tab {
children: Vec<AnyElement>,
pub(super) disabled: bool,
pub(super) selected: bool,
pub(super) segmented: bool,
on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
}
@@ -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)
})
}),
)
})
}
}
+19 -3
View File
@@ -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<usize>,
menu: bool,
segmented: bool,
#[allow(clippy::type_complexity)]
on_click: Option<Rc<dyn Fn(&usize, &mut Window, &mut App) + 'static>>,
}
@@ -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)
})
+329 -410
View File
@@ -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<DockArea>,
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<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.
fn reset_community(&mut self, cx: &mut Context<Self>) {
if self.community.take().is_none() {
@@ -155,9 +119,12 @@ impl Sidebar {
cx.notify();
}
fn render_user(&self, cx: &mut Context<Self>) -> Stateful<Div> {
let nostr = NostrRegistry::global(cx);
let current_user = nostr.read(cx).current_user();
fn render_user(&self, current_user: &PublicKey, cx: &mut Context<Self>) -> Stateful<Div> {
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<AutoUpdater>, 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<Self>) -> 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<Community>,
cx: &mut Context<Self>,
) -> 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::<Vec<_>>(),
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<Entity<Community>> {
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<Entity<Community>> = recent
.iter()
.filter_map(|id| {
communities
.iter()
.find(|community| community.read(cx).id().to_hex() == *id)
fn render_community_rows(
&self,
range: Range<usize>,
rows: &[CommunityRow],
community: &Entity<Community>,
cx: &mut Context<Self>,
) -> Vec<AnyElement> {
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<SidebarRow> {
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<Sidebar>,
@@ -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<Community>,
@@ -875,7 +758,11 @@ impl Focusable for Sidebar {
impl Render for Sidebar {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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()
}
}
+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)]
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<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 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<Room> },
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 {
room: Entity<Room>,
},
Community {
community: Entity<Community>,
},
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<SharedString>,
picture: Option<ImageSource>,
icon: Option<IconName>,
count: Option<usize>,
created_at: Option<SharedString>,
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<SharedString>) -> 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)